From 4c1841b61b8c2b08dceeed49ee2157cfe42691d6 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sat, 19 Sep 2026 13:13:29 +0300 Subject: [PATCH] =?UTF-8?q?ros=5Fcontrol:=20=D1=86=D0=B5=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=B0=D1=80=D0=BA=D0=BE=D0=BC=20MikroTik=20Rout?= =?UTF-8?q?erOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control Server (FastAPI) с WEB UI (Jinja2 + HTMX) и JSON API для группового администрирования устройств RouterOS 7 через REST. - Устройства: список, статус (модель, канал, версии ROS/FW, uptime, доступные обновления), примечания, пароли шифруются (Fernet); фоновый опрос каждые 30 с и быстрое обнаружение недоступности (таймаут соединения 4 с). - Группы устройств и фильтры (устройства, резервные копии). - Резервные копии: .backup и .rsc (show-sensitive) создаются через REST, скачиваются сервером и загружаются в S3 (Yandex Object Storage). - Обновление ROS/FW и выбор канала, групповые операции задачами. - Интерфейс по утверждённому макету: светлая/тёмная темы, окна, шрифты IBM Plex локально. Docker Compose, SQLite в томе, минимальный набор тестов. - Планы и итоги каждого изменения — в docs/changes/. Co-Authored-By: Claude Sonnet 5 --- .claude/CLAUDE.md | 30 ++ .dockerignore | 6 + .env.example | 29 ++ .gitignore | 6 + Dockerfile | 10 + README.md | 135 +++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/v1.py | 258 ++++++++++++ app/config.py | 33 ++ app/db.py | 55 +++ app/main.py | 60 +++ app/models.py | 72 ++++ app/ros/__init__.py | 0 app/ros/client.py | 68 ++++ app/ros/operations.py | 185 +++++++++ app/s3.py | 61 +++ app/security.py | 37 ++ app/services/__init__.py | 0 app/services/backups.py | 49 +++ app/services/devices.py | 168 ++++++++ app/services/groups.py | 68 ++++ app/services/jobs.py | 81 ++++ app/services/ops.py | 124 ++++++ app/services/poller.py | 45 +++ app/ui/__init__.py | 0 app/ui/icons.py | 15 + app/ui/routes.py | 379 ++++++++++++++++++ app/ui/static/app.js | 105 +++++ app/ui/static/fonts/OFL.txt | 93 +++++ .../ibm-plex-mono-cyrillic-400-normal.woff2 | Bin 0 -> 8356 bytes .../ibm-plex-mono-cyrillic-500-normal.woff2 | Bin 0 -> 8460 bytes .../ibm-plex-mono-latin-400-normal.woff2 | Bin 0 -> 14708 bytes .../ibm-plex-mono-latin-500-normal.woff2 | Bin 0 -> 14888 bytes .../ibm-plex-sans-cyrillic-400-normal.woff2 | Bin 0 -> 14800 bytes .../ibm-plex-sans-cyrillic-500-normal.woff2 | Bin 0 -> 15000 bytes .../ibm-plex-sans-cyrillic-600-normal.woff2 | Bin 0 -> 15040 bytes .../ibm-plex-sans-latin-400-normal.woff2 | Bin 0 -> 22588 bytes .../ibm-plex-sans-latin-500-normal.woff2 | Bin 0 -> 24184 bytes .../ibm-plex-sans-latin-600-normal.woff2 | Bin 0 -> 24252 bytes app/ui/static/htmx.min.js | 1 + app/ui/static/style.css | 202 ++++++++++ app/ui/templates/_dev_sub.html | 1 + app/ui/templates/_device_form.html | 51 +++ app/ui/templates/_devices.html | 75 ++++ app/ui/templates/_group_form.html | 18 + app/ui/templates/_jobs.html | 31 ++ app/ui/templates/_ui.html | 4 + app/ui/templates/backups.html | 70 ++++ app/ui/templates/base.html | 28 ++ app/ui/templates/dashboard.html | 85 ++++ app/ui/templates/device_form.html | 4 + app/ui/templates/groups.html | 45 +++ app/ui/templates/login.html | 12 + docker-compose.yml | 14 + .../001-initial-implementation/plan.md | 76 ++++ .../001-initial-implementation/summary.md | 32 ++ .../002-http-support-and-fixes/plan.md | 15 + .../002-http-support-and-fixes/summary.md | 19 + .../plan.md | 16 + .../summary.md | 25 ++ .../plan.md | 46 +++ .../summary.md | 19 + docs/changes/005-groups-and-filters/plan.md | 56 +++ .../changes/005-groups-and-filters/summary.md | 29 ++ docs/changes/006-ui-redesign/plan.md | 33 ++ docs/changes/006-ui-redesign/summary.md | 36 ++ .../007-fast-offline-detection/plan.md | 14 + .../007-fast-offline-detection/summary.md | 19 + docs/changes/008-device-note/plan.md | 8 + docs/changes/008-device-note/summary.md | 14 + .../009-dialog-label-alignment/plan.md | 5 + .../009-dialog-label-alignment/summary.md | 5 + .../changes/010-export-show-sensitive/plan.md | 4 + .../010-export-show-sensitive/summary.md | 7 + docs/changes/011-dropdown-clipping/plan.md | 4 + docs/changes/011-dropdown-clipping/summary.md | 6 + .../changes/012-clickable-device-name/plan.md | 4 + .../012-clickable-device-name/summary.md | 5 + pytest.ini | 2 + requirements.txt | 12 + ros_control.svg | 4 + tests/__init__.py | 0 tests/conftest.py | 20 + tests/test_app.py | 231 +++++++++++ 85 files changed, 3579 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/ros/__init__.py create mode 100644 app/ros/client.py create mode 100644 app/ros/operations.py create mode 100644 app/s3.py create mode 100644 app/security.py create mode 100644 app/services/__init__.py create mode 100644 app/services/backups.py create mode 100644 app/services/devices.py create mode 100644 app/services/groups.py create mode 100644 app/services/jobs.py create mode 100644 app/services/ops.py create mode 100644 app/services/poller.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/icons.py create mode 100644 app/ui/routes.py create mode 100644 app/ui/static/app.js create mode 100644 app/ui/static/fonts/OFL.txt create mode 100644 app/ui/static/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-mono-cyrillic-500-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-mono-latin-400-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-mono-latin-500-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-cyrillic-400-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-latin-400-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-latin-500-normal.woff2 create mode 100644 app/ui/static/fonts/ibm-plex-sans-latin-600-normal.woff2 create mode 100644 app/ui/static/htmx.min.js create mode 100644 app/ui/static/style.css create mode 100644 app/ui/templates/_dev_sub.html create mode 100644 app/ui/templates/_device_form.html create mode 100644 app/ui/templates/_devices.html create mode 100644 app/ui/templates/_group_form.html create mode 100644 app/ui/templates/_jobs.html create mode 100644 app/ui/templates/_ui.html create mode 100644 app/ui/templates/backups.html create mode 100644 app/ui/templates/base.html create mode 100644 app/ui/templates/dashboard.html create mode 100644 app/ui/templates/device_form.html create mode 100644 app/ui/templates/groups.html create mode 100644 app/ui/templates/login.html create mode 100644 docker-compose.yml create mode 100644 docs/changes/001-initial-implementation/plan.md create mode 100644 docs/changes/001-initial-implementation/summary.md create mode 100644 docs/changes/002-http-support-and-fixes/plan.md create mode 100644 docs/changes/002-http-support-and-fixes/summary.md create mode 100644 docs/changes/003-download-via-rest-upload-from-server/plan.md create mode 100644 docs/changes/003-download-via-rest-upload-from-server/summary.md create mode 100644 docs/changes/004-upgrade-columns-and-actions-menu/plan.md create mode 100644 docs/changes/004-upgrade-columns-and-actions-menu/summary.md create mode 100644 docs/changes/005-groups-and-filters/plan.md create mode 100644 docs/changes/005-groups-and-filters/summary.md create mode 100644 docs/changes/006-ui-redesign/plan.md create mode 100644 docs/changes/006-ui-redesign/summary.md create mode 100644 docs/changes/007-fast-offline-detection/plan.md create mode 100644 docs/changes/007-fast-offline-detection/summary.md create mode 100644 docs/changes/008-device-note/plan.md create mode 100644 docs/changes/008-device-note/summary.md create mode 100644 docs/changes/009-dialog-label-alignment/plan.md create mode 100644 docs/changes/009-dialog-label-alignment/summary.md create mode 100644 docs/changes/010-export-show-sensitive/plan.md create mode 100644 docs/changes/010-export-show-sensitive/summary.md create mode 100644 docs/changes/011-dropdown-clipping/plan.md create mode 100644 docs/changes/011-dropdown-clipping/summary.md create mode 100644 docs/changes/012-clickable-device-name/plan.md create mode 100644 docs/changes/012-clickable-device-name/summary.md create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 ros_control.svg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..be2e2e2 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Твоя роль + +- DevOps инженер +- Разработчик Backend +- Архитектор информационных систем +- Архитектор корпоративной сети + +# Стиль общения + +- профессиональный, но без жаргона + +# Стиль ответов + +- максимально емкие и содержательные +- не проваливайся в лишние детали, если это явно не было запрошено + +# Создание артефактов + +- На каждое новое изменение должен быть артефакт в .md файле +- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле +- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле +- каждое изменение дополняет или обновляет README.md + +# Автотесты + +- минимальное количество тестов + +# Окружение для разработки + +- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..986cf6a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +venv +data +.env +tests +docs +*.svg diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c2471a --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# База метаданных +DATABASE_URL=sqlite:///./data/ros_control.db + +# Ключ Fernet для шифрования паролей устройств: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +SECRET_KEY= +# Секрет подписи cookie-сессии UI +SESSION_SECRET=change-me + +# Администратор UI и токен API +ADMIN_USER=admin +ADMIN_PASSWORD=change-me +API_TOKEN=change-me + +# Yandex Object Storage (S3) +S3_ENDPOINT=https://storage.yandexcloud.net +S3_REGION=ru-central1 +S3_BUCKET= +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_PREFIX=backups + +# Ограничение параллельных обращений к устройствам +MAX_CONCURRENCY=10 + +# Обнаружение недоступности устройств +ROS_CONNECT_TIMEOUT=4 # секунд на установление соединения +POLL_INTERVAL=30 # период фонового опроса, секунд (0 — выключить) +UPDATE_CHECK_INTERVAL=1800 # как часто проверять обновления ROS в опросе, секунд diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca8e5de --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +data/ +.env +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d01480 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +WORKDIR /srv +ENV PYTHONUNBUFFERED=1 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +RUN useradd -r -u 10001 app && mkdir -p /srv/data && chown app /srv/data +USER app +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..738b699 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# 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). + +## Интерфейс + +Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными. +Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются. + +## Требования к устройствам + +- RouterOS **7.1+**, включённый сервис `www-ssl` (REST API, порт 443; **не** `api-ssl` 8729 и не `api` 8728). Если `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` — без шифрования, поэтому файлы содержат пароли и ключи открытым текстом. Ограничьте доступ к бакету и ссылкам скачивания. + +## Запуск + +```bash +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`), переживает пересборку контейнера; схема обновляется автоматически при старте. Остановка: `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` | бакет для резервных копий | +| `MAX_CONCURRENCY` | `10` | одновременные обращения к устройствам | +| `ROS_CONNECT_TIMEOUT` | `4` | секунд на установление соединения (скорость обнаружения недоступности) | +| `ROS_TIMEOUT` | `30` | секунд на ответ устройства (долгие операции) | +| `POLL_INTERVAL` | `30` | период фонового опроса, с; `0` — выключить | +| `UPDATE_CHECK_INTERVAL` | `1800` | как часто опрос проверяет обновления ROS, с | + +### Разработка + +```bash +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 # 9 тестов, фоновый опрос в тестах выключен +``` + +## API v1 + +Заголовок `Authorization: Bearer `. Интерактивная документация — `/docs`. Пример: + +```bash +curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devices | python3 -m json.tool +``` + +Устройство: `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}` | получить / изменить / удалить | +| 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": "..."}` | +| GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`) | +| GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач | + +Внешние справочники: [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API#RESTAPI-HTTPMethods), +[Yandex Object Storage S3 API](https://yandex.cloud/ru/docs/storage/s3/api-ref/). + +## Структура + +- `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/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` — клик по имени устройства открывает окно изменения. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1.py b/app/api/v1.py new file mode 100644 index 0000000..d9d1596 --- /dev/null +++ b/app/api/v1.py @@ -0,0 +1,258 @@ +from datetime import date, datetime +from typing import Literal + +from fastapi import APIRouter, Depends +from fastapi.responses import RedirectResponse +from pydantic import BaseModel, Field + +from app import s3 +from app.ros.operations import CHANNELS +from app.security import require_api_token +from app.services import backups, devices, groups, jobs, ops + +router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_token)], tags=["api"]) + + +class DeviceIn(BaseModel): + name: str + host: str + port: int = 443 + username: str + password: str + verify_tls: bool = False + use_tls: bool = True # False = HTTP (сервис www, порт 80): пароль идёт открытым текстом + group_id: int | None = None + note: str | None = None + + +class DevicePatch(BaseModel): + name: str | None = None + host: str | None = None + port: int | None = None + username: str | None = None + password: str | None = None + verify_tls: bool | None = None + use_tls: bool | None = None + group_id: int | None = None # явный null — открепить от группы + note: str | None = None # пустая строка или null — очистить примечание + + +class DeviceOut(BaseModel): + """Пароль устройства в ответы не попадает.""" + id: int + name: str + host: str + port: int + username: str + verify_tls: bool + use_tls: bool + group_id: int | None + note: str | None + online: bool | None + status: dict + status_at: datetime | None + last_error: str | None + last_backup_requested_at: datetime | None + + @classmethod + def of(cls, d) -> "DeviceOut": + return cls(**{k: getattr(d, k) for k in cls.model_fields}) + + +class JobOut(BaseModel): + id: int + device_id: int | None + device_name: str + type: str + status: str + message: str + created_at: datetime + finished_at: datetime | None + + model_config = {"from_attributes": True} + + +class ChannelIn(BaseModel): + channel: Literal[CHANNELS] # type: ignore[valid-type] + + +class GroupIn(BaseModel): + name: str + + +class GroupOut(BaseModel): + id: int + name: str + device_count: int = 0 + + +class BatchIn(BaseModel): + """Устройства, над которыми выполняется операция: список id и/или целая группа.""" + device_ids: list[int] = Field(default_factory=list) + group_id: int | None = None + + def resolve(self) -> list[int]: + ids = list(self.device_ids) + if self.group_id is not None: + groups.get_group(self.group_id) # 404, если группы нет + ids += [d.id for d in devices.list_devices() if d.group_id == self.group_id] + ids = list(dict.fromkeys(ids)) + if not ids: + raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id") + return ids + + +class BatchChannelIn(BatchIn): + channel: Literal[CHANNELS] # type: ignore[valid-type] + + +# --- устройства --- + +@router.get("/devices", response_model=list[DeviceOut]) +async def list_devices( + group: str = "", # "" — все, "none" — без группы, иначе id группы + q: str = "", + status: Literal["", "online", "offline"] = "", + updates: Literal["", "ros", "fw", "any", "none"] = "", + channel: str = "", +): + found = devices.filter_devices(devices.list_devices(), group, q, status, updates, channel) + return [DeviceOut.of(d) for d in found] + + +# --- группы --- + +@router.get("/groups", response_model=list[GroupOut]) +async def list_groups(): + return groups.list_groups() + + +@router.post("/groups", response_model=GroupOut, status_code=201) +async def create_group(body: GroupIn): + g = groups.create_group(body.name) + return GroupOut(id=g.id, name=g.name) + + +@router.patch("/groups/{group_id}", response_model=GroupOut) +async def rename_group(group_id: int, body: GroupIn): + groups.rename_group(group_id, body.name) + return next(g for g in groups.list_groups() if g["id"] == group_id) + + +@router.delete("/groups/{group_id}", status_code=204) +async def delete_group(group_id: int): + groups.delete_group(group_id) + + +@router.post("/devices", response_model=DeviceOut, status_code=201) +async def create_device(body: DeviceIn): + return DeviceOut.of(devices.create_device(**body.model_dump())) + + +@router.get("/devices/{device_id}", response_model=DeviceOut) +async def get_device(device_id: int): + return DeviceOut.of(devices.get_device(device_id)) + + +@router.patch("/devices/{device_id}", response_model=DeviceOut) +async def patch_device(device_id: int, body: DevicePatch): + return DeviceOut.of(devices.update_device(device_id, **body.model_dump(exclude_unset=True))) + + +@router.delete("/devices/{device_id}", status_code=204) +async def delete_device(device_id: int): + devices.delete_device(device_id) + + +@router.post("/devices/{device_id}/refresh", response_model=DeviceOut) +async def refresh_device(device_id: int): + devices.get_device(device_id) + await ops.refresh_status(device_id) + return DeviceOut.of(devices.get_device(device_id)) + + +@router.post("/devices/refresh", response_model=list[DeviceOut]) +async def refresh_all(): + await ops.refresh_many() + return [DeviceOut.of(d) for d in devices.list_devices()] + + +# --- операции (долгие — через jobs) --- + +@router.post("/devices/{device_id}/backups", status_code=202) +async def create_backup(device_id: int): + return {"job_ids": jobs.start_jobs("backup", [device_id])} + + +@router.put("/devices/{device_id}/update/channel", response_model=DeviceOut) +async def put_channel(device_id: int, body: ChannelIn): + devices.get_device(device_id) + await ops.set_channel(device_id, body.channel) + return DeviceOut.of(devices.get_device(device_id)) + + +@router.post("/devices/{device_id}/update/install", status_code=202) +async def install_update(device_id: int): + return {"job_ids": jobs.start_jobs("ros_update", [device_id])} + + +@router.post("/devices/{device_id}/firmware/upgrade", status_code=202) +async def upgrade_firmware(device_id: int): + return {"job_ids": jobs.start_jobs("fw_update", [device_id])} + + +@router.post("/batch/{action}", status_code=202) +async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn): + """Групповая операция над списком устройств.""" + return {"job_ids": jobs.start_jobs(action, body.resolve())} + + +@router.put("/batch/channel", response_model=list[DeviceOut]) +async def batch_channel(body: BatchChannelIn): + ids = body.resolve() + for i in ids: + devices.get_device(i) + for i in ids: + await ops.set_channel(i, body.channel) + return [DeviceOut.of(devices.get_device(i)) for i in ids] + + +# --- резервные копии в S3 --- + +@router.get("/backups") +async def list_backups( + device_id: int | None = None, + group: str = "", # "" — все, "none" — без группы, иначе id группы + kind: Literal["", "backup", "rsc"] = "", + date_from: date | None = None, + date_to: date | None = None, + q: str = "", +): + name = devices.get_device(device_id).name if device_id else "" + return await backups.list_backups(name, group, kind, date_from, date_to, q) + + +@router.get("/backups/download") +async def download_backup(key: str): + if not s3.key_allowed(key): + raise ValueError("Недопустимый ключ") + return RedirectResponse(s3.presign_get(key)) + + +@router.delete("/backups", status_code=204) +async def delete_backup(key: str): + if not s3.key_allowed(key): + raise ValueError("Недопустимый ключ") + await s3.delete_object(key) + + +# --- задачи --- + +@router.get("/jobs", response_model=list[JobOut]) +async def list_jobs(): + return jobs.list_jobs() + + +@router.get("/jobs/{job_id}", response_model=JobOut) +async def get_job(job_id: int): + return jobs.get_job(job_id) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4ac9d8b --- /dev/null +++ b/app/config.py @@ -0,0 +1,33 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "sqlite:///./data/ros_control.db" + secret_key: str = "" # ключ Fernet для паролей устройств + session_secret: str = "change-me" + + admin_user: str = "admin" + admin_password: str = "change-me" + api_token: str = "change-me" + + s3_endpoint: str = "https://storage.yandexcloud.net" + s3_region: str = "ru-central1" + s3_bucket: str = "" + s3_access_key: str = "" + s3_secret_key: str = "" + s3_prefix: str = "backups" + + max_concurrency: int = 10 + ros_timeout: float = 30.0 # секунд на чтение ответа устройства (долгие операции) + ros_connect_timeout: float = 4.0 # секунд на установление соединения: так быстро определяется недоступность + poll_interval: int = 30 # период фонового опроса устройств, с; 0 — отключить + update_check_interval: int = 1800 # как часто в опросе проверять обновления ROS (тяжёлый запрос), с + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..cdca38d --- /dev/null +++ b/app/db.py @@ -0,0 +1,55 @@ +from contextlib import contextmanager +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +_engine = None +_SessionLocal = None + + +def init_db(url: str | None = None) -> None: + """Создаёт engine и таблицы. Вызывается при старте приложения (и в тестах).""" + global _engine, _SessionLocal + url = url or get_settings().database_url + if url.startswith("sqlite:///") and ":memory:" not in url: + Path(url.removeprefix("sqlite:///")).parent.mkdir(parents=True, exist_ok=True) + _engine = create_engine(url, connect_args={"check_same_thread": False}) + _SessionLocal = sessionmaker(_engine, expire_on_commit=False) + from app import models # noqa: F401 (регистрация моделей) + + Base.metadata.create_all(_engine) + _migrate() + + +def _migrate() -> None: + """create_all не добавляет колонки в существующие таблицы — докидываем вручную.""" + with _engine.begin() as conn: + cols = {r[1] for r in conn.exec_driver_sql("PRAGMA table_info(devices)")} + if "use_tls" not in cols: + conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN use_tls BOOLEAN NOT NULL DEFAULT 1") + if "group_id" not in cols: + conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN group_id INTEGER") + if "note" not in cols: + conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN note TEXT") + + +@contextmanager +def session_scope(): + """Короткая транзакция: commit при успехе, rollback при ошибке.""" + session = _SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..062b676 --- /dev/null +++ b/app/main.py @@ -0,0 +1,60 @@ +import asyncio +from contextlib import asynccontextmanager, suppress +from pathlib import Path + +from botocore.exceptions import BotoCoreError, ClientError +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware + +from app.api import v1 +from app.config import get_settings +from app.db import init_db +from app.services import jobs, poller +from app.ui import routes as ui + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + jobs.fail_stale_jobs() + task = asyncio.create_task(poller.run()) if get_settings().poll_interval > 0 else None + yield + if task: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +def create_app() -> FastAPI: + app = FastAPI(title="ros_control", lifespan=lifespan) + app.add_middleware( + SessionMiddleware, secret_key=get_settings().session_secret, + same_site="strict", https_only=False, + ) + app.mount("/static", StaticFiles(directory=Path(__file__).parent / "ui" / "static"), name="static") + app.include_router(v1.router) + app.include_router(ui.router) + + @app.exception_handler(ui.LoginRequired) + async def _login(_: Request, e: ui.LoginRequired): + return RedirectResponse("/login", status_code=303) + + @app.exception_handler(LookupError) + async def _404(_: Request, e: LookupError): + return JSONResponse({"detail": str(e).strip("'\"")}, status_code=404) + + @app.exception_handler(ValueError) + async def _400(_: Request, e: ValueError): + return JSONResponse({"detail": str(e)}, status_code=400) + + @app.exception_handler(BotoCoreError) + @app.exception_handler(ClientError) + async def _502(_: Request, e: Exception): + return JSONResponse({"detail": f"S3: {e}"}, status_code=502) + + return app + + +app = create_app() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..d7b22b5 --- /dev/null +++ b/app/models.py @@ -0,0 +1,72 @@ +import json +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +class Group(Base): + __tablename__ = "device_groups" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + + +class Device(Base): + __tablename__ = "devices" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + host: Mapped[str] = mapped_column(String(255)) + port: Mapped[int] = mapped_column(Integer, default=443) + username: Mapped[str] = mapped_column(String(64)) + password_enc: Mapped[str] = mapped_column(Text) + verify_tls: Mapped[bool] = mapped_column(Boolean, default=False) + use_tls: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1") + group_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # FK не включены — см. groups.delete_group + note: Mapped[str | None] = mapped_column(Text, nullable=True) # свободное примечание к устройству + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + # кэш последнего опроса устройства + online: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + status_json: Mapped[str] = mapped_column(Text, default="{}") + status_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + last_backup_requested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + @property + def status(self) -> dict: + return json.loads(self.status_json or "{}") + + +class Backup(Base): + __tablename__ = "backups" + + id: Mapped[int] = mapped_column(primary_key=True) + device_id: Mapped[int] = mapped_column(ForeignKey("devices.id", ondelete="CASCADE")) + requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + status: Mapped[str] = mapped_column(String(16), default="running") # running|done|failed + key_binary: Mapped[str | None] = mapped_column(String(512), nullable=True) + key_rsc: Mapped[str | None] = mapped_column(String(512), nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class Job(Base): + __tablename__ = "jobs" + + id: Mapped[int] = mapped_column(primary_key=True) + device_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + device_name: Mapped[str] = mapped_column(String(64), default="") + type: Mapped[str] = mapped_column(String(32)) # backup|ros_update|fw_update + status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|done|failed + message: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/ros/__init__.py b/app/ros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ros/client.py b/app/ros/client.py new file mode 100644 index 0000000..012ecc2 --- /dev/null +++ b/app/ros/client.py @@ -0,0 +1,68 @@ +import json as jsonlib + +import httpx + + +class RosError(Exception): + """Ошибка обращения к устройству RouterOS. status=None — сбой на уровне соединения.""" + + def __init__(self, message: str, status: int | None = None): + super().__init__(message) + self.status = status + + +class RosClient: + """Тонкий клиент RouterOS REST API (https://:/rest/...).""" + + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + verify_tls: bool = False, + timeout: float = 30.0, + use_tls: bool = True, + transport: httpx.AsyncBaseTransport | None = None, + connect_timeout: float = 4.0, + ): + self._connect = connect_timeout + self._http = httpx.AsyncClient( + base_url=f"{'https' if use_tls else 'http'}://{host}:{port}/rest", + auth=(username, password), + verify=verify_tls, + timeout=httpx.Timeout(timeout, connect=connect_timeout), + transport=transport, + ) + + async def __aenter__(self) -> "RosClient": + return self + + async def __aexit__(self, *exc) -> None: + await self._http.aclose() + + async def request(self, method: str, path: str, json: dict | None = None, + params: dict | None = None, timeout: float | None = None): + try: + kwargs = {"timeout": httpx.Timeout(timeout, connect=self._connect)} if timeout else {} + r = await self._http.request(method, path, json=json, params=params, **kwargs) + except httpx.HTTPError as e: + raise RosError(f"{self._http.base_url}: {type(e).__name__}: {e or repr(e.__cause__)}") from e + if r.status_code >= 400: + try: + body = jsonlib.loads(r.content.decode("utf-8", "replace")) + detail = body.get("detail") or body.get("message") or r.text + except ValueError: + detail = r.text + raise RosError(f"HTTP {r.status_code}: {detail}", status=r.status_code) + # RouterOS может отдавать имена файлов не в UTF-8 — декодируем с заменой, а не падаем + return jsonlib.loads(r.content.decode("utf-8", "replace")) if r.content else None + + async def get(self, path: str, **params): + return await self.request("GET", path, params=params or None) + + async def post(self, path: str, json: dict | None = None, timeout: float | None = None): + return await self.request("POST", path, json=json or {}, timeout=timeout) + + async def delete(self, path: str): + return await self.request("DELETE", path) diff --git a/app/ros/operations.py b/app/ros/operations.py new file mode 100644 index 0000000..cf74010 --- /dev/null +++ b/app/ros/operations.py @@ -0,0 +1,185 @@ +"""Операции над устройством через RouterOS REST. Не знают про БД и S3.""" +import asyncio +import base64 +import re +from datetime import datetime, timedelta +from pathlib import Path + +from app.ros.client import RosClient, RosError + +CHANNELS = ("stable", "long-term", "testing", "development") + + +async def get_status(c: RosClient, check_updates: bool = True) -> dict: + """Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления. + + latest-version в system/package/update появляется только после check-for-updates + (устройство обращается к серверам MikroTik). Сбой проверки не считается сбоем опроса. + """ + resource, board, update = await asyncio.gather( + c.get("system/resource"), + c.get("system/routerboard"), + c.get("system/package/update"), + ) + check_error = None + if check_updates: + try: + await c.post("system/package/update/check-for-updates", timeout=30) + update = await c.get("system/package/update") + except RosError as e: + check_error = str(e) + is_board = str(board.get("routerboard", "false")) == "true" + return { + "model": (board.get("model") if is_board else None) or resource.get("board-name"), + "channel": update.get("channel"), + "ros_version": resource.get("version"), + "ros_installed": update.get("installed-version"), # без суффикса «(stable)» — для сравнения + "ros_latest": update.get("latest-version"), + "ros_update_status": update.get("status"), + "ros_check_error": check_error, + "fw_current": board.get("current-firmware") if is_board else None, + "fw_upgrade": board.get("upgrade-firmware") if is_board else None, + "uptime": resource.get("uptime"), + } + + +async def wait_file(c: RosClient, name: str, attempts: int = 15) -> str: + """Ждёт появления файла на устройстве и возвращает его .id.""" + for _ in range(attempts): + files = await c.get("file", name=name) + if files: + return files[0][".id"] + await asyncio.sleep(1) + raise RosError(f"Файл {name} не появился на устройстве") + + +async def create_backup_files(c: RosClient, base: str) -> tuple[str, str]: + """Создаёт бинарный бэкап (без шифрования) и .rsc-экспорт (с секретами). Возвращает имена файлов.""" + binary, rsc = f"{base}.backup", f"{base}.rsc" + await c.post("system/backup/save", {"name": base, "dont-encrypt": "yes"}) + # show-sensitive: в .rsc попадают пароли, ключи и секреты — из файла можно восстановить всё, что их использует + await c.post("export", {"file": base, "show-sensitive": ""}) + await wait_file(c, binary) + await wait_file(c, rsc) + return binary, rsc + + +async def download_file(c: RosClient, name: str, dest: Path, chunk_size: int = 32768) -> int: + """Скачивает файл блоками через REST (/file/read). REST не отдаёт содержимое больших файлов + через GET /file, а бинарные данные в JSON ненадёжны, поэтому блок читается скриптом и + передаётся в base64. Возвращает размер файла; сверяет его с размером на устройстве.""" + files = await c.get("file", name=name) + if not files: + raise RosError(f"Файл {name} не найден на устройстве") + size = int(files[0]["size"]) + received = 0 + with dest.open("wb") as f: + while received < size: + script = (f':put [:convert to=base64 ([/file/read file="{name}" offset={received} ' + f'chunk-size={chunk_size} as-value]->"data")]') + res = await c.post("execute", {"script": script, "as-string": ""}) + chunk = base64.b64decode((res or {}).get("ret", "")) + if not chunk: + raise RosError(f"Пустой блок при чтении {name} со смещения {received}") + f.write(chunk) + received += len(chunk) + if received != size: + raise RosError(f"Размер {name}: получено {received} из {size} байт") + return size + + +async def remove_file(c: RosClient, filename: str) -> None: + files = await c.get("file", name=filename) + for f in files or []: + await c.delete(f"file/{f['.id']}") + + +async def set_channel(c: RosClient, channel: str) -> None: + if channel not in CHANNELS: + raise ValueError(f"Неизвестный канал: {channel}") + await c.post("system/package/update/set", {"channel": channel}) + + +async def install_ros_update(c: RosClient) -> str: + """Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания.""" + await c.post("system/package/update/check-for-updates", timeout=120) + u = await c.get("system/package/update") + installed, latest = u.get("installed-version"), u.get("latest-version") + if not latest or installed == latest: + return f"Обновление не требуется (версия {installed})" + try: + await c.post("system/package/update/install", timeout=15) + except RosError as e: + # после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно + if e.status is not None: + raise + return f"Обновление {installed} → {latest} запущено, устройство перезагрузится" + + +FW_DONE_MSG = "Firmware upgraded successfully, please reboot for changes to take effect!" +_UPTIME_RE = re.compile(r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") + + +def parse_uptime(value: str | None) -> int | None: + """'1w2d3h4m5s' -> секунды; None, если формат не распознан.""" + m = _UPTIME_RE.match(value or "") + if not m or not any(m.groups()): + return None + w, d, h, mi, sec = (int(x or 0) for x in m.groups()) + return (((w * 7 + d) * 24 + h) * 60 + mi) * 60 + sec + + +def _ts(value: str | None) -> datetime | None: + try: + return datetime.strptime(value or "", "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +async def _fw_done_entries(c: RosClient) -> list[dict]: + # фильтр REST ?message=… по точному тексту ничего не возвращает — фильтруем сами + log = await c.get("log", **{".proplist": ".id,time,message"}) + return [e for e in log or [] if e.get("message") == FW_DONE_MSG] + + +async def _booted_at(c: RosClient) -> datetime | None: + """Время последней загрузки устройства по его часам (в той же зоне, что и время журнала).""" + clock, res = await asyncio.gather(c.get("system/clock"), c.get("system/resource")) + now = _ts(f"{clock.get('date')} {clock.get('time')}") + up = parse_uptime(res.get("uptime")) + return now - timedelta(seconds=up) if now and up is not None else None + + +async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str: + """Обновляет FW и перезагружает устройство, как только в журнале появилась запись + «Firmware upgraded successfully…». current-firmware до перезагрузки не меняется, + поэтому по нему завершение определить нельзя.""" + board = await c.get("system/routerboard") + if str(board.get("routerboard", "false")) != "true": + return "Устройство без RouterBOARD firmware (например, CHR) — пропущено" + if board.get("current-firmware") == board.get("upgrade-firmware"): + return f"Прошивка актуальна ({board.get('current-firmware')})" + + before = await _fw_done_entries(c) + booted = await _booted_at(c) + if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before): + # прошивка уже записана после последней загрузки, ждём только перезагрузки + await _reboot(c) + return "Прошивка уже записана (запись в журнале), перезагрузка отправлена" + + known = {e[".id"] for e in before} + await c.post("system/routerboard/upgrade") + for _ in range(int(wait_seconds / poll) if poll else 1000): + if any(e[".id"] not in known for e in await _fw_done_entries(c)): + await _reboot(c) + return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена" + await asyncio.sleep(poll) + raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время") + + +async def _reboot(c: RosClient) -> None: + try: + await c.post("system/reboot", timeout=10) + except RosError as e: + if e.status is not None: # обрыв соединения при перезагрузке — штатно + raise diff --git a/app/s3.py b/app/s3.py new file mode 100644 index 0000000..1a27480 --- /dev/null +++ b/app/s3.py @@ -0,0 +1,61 @@ +"""Yandex Object Storage (S3-совместимый): ListObjectsV2, GetObject, PutObject, DeleteObject.""" +import asyncio +from functools import lru_cache + +import boto3 +from botocore.config import Config + +from app.config import get_settings + + +@lru_cache +def _client(): + s = get_settings() + return boto3.client( + "s3", + endpoint_url=s.s3_endpoint, + region_name=s.s3_region, + aws_access_key_id=s.s3_access_key, + aws_secret_access_key=s.s3_secret_key, + config=Config(signature_version="s3v4"), + ) + + +def device_prefix(device_name: str) -> str: + return f"{get_settings().s3_prefix}/{device_name}/" + + +def key_allowed(key: str) -> bool: + """Ключ должен лежать под префиксом бэкапов (защита от произвольных ключей бакета).""" + return key.startswith(get_settings().s3_prefix + "/") and ".." not in key + + +def presign_get(key: str, expires: int = 300) -> str: + return _client().generate_presigned_url( + "get_object", Params={"Bucket": get_settings().s3_bucket, "Key": key}, ExpiresIn=expires + ) + + +def _list(prefix: str) -> list[dict]: + paginator = _client().get_paginator("list_objects_v2") + items = [] + for page in paginator.paginate(Bucket=get_settings().s3_bucket, Prefix=prefix): + for o in page.get("Contents", []): + items.append({"key": o["Key"], "size": o["Size"], "last_modified": o["LastModified"]}) + return sorted(items, key=lambda i: i["last_modified"], reverse=True) + + +async def list_backups(device_name: str | None = None) -> list[dict]: + prefix = device_prefix(device_name) if device_name else get_settings().s3_prefix + "/" + return await asyncio.to_thread(_list, prefix) + + +async def upload_file(path, key: str) -> None: + """PutObject: загрузка файла в бакет от имени Control Server.""" + await asyncio.to_thread(_client().upload_file, str(path), get_settings().s3_bucket, key) + + +async def delete_object(key: str) -> None: + await asyncio.to_thread( + _client().delete_object, Bucket=get_settings().s3_bucket, Key=key + ) diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..7c23f99 --- /dev/null +++ b/app/security.py @@ -0,0 +1,37 @@ +import hmac + +from cryptography.fernet import Fernet +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.config import get_settings + +_bearer = HTTPBearer(auto_error=False) + + +def _fernet() -> Fernet: + key = get_settings().secret_key + if not key: + raise RuntimeError("SECRET_KEY не задан (ключ Fernet)") + return Fernet(key.encode()) + + +def encrypt(value: str) -> str: + return _fernet().encrypt(value.encode()).decode() + + +def decrypt(token: str) -> str: + return _fernet().decrypt(token.encode()).decode() + + +def check_admin(user: str, password: str) -> bool: + s = get_settings() + ok_user = hmac.compare_digest(user.encode(), s.admin_user.encode()) + ok_pass = hmac.compare_digest(password.encode(), s.admin_password.encode()) + return ok_user and ok_pass + + +def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None: + expected = get_settings().api_token + if cred is None or not hmac.compare_digest(cred.credentials.encode(), expected.encode()): + raise HTTPException(status_code=401, detail="Invalid or missing API token") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/backups.py b/app/services/backups.py new file mode 100644 index 0000000..0bc4721 --- /dev/null +++ b/app/services/backups.py @@ -0,0 +1,49 @@ +"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст).""" +from datetime import date + +from app import s3 +from app.services import devices, groups + + +def _kind(key: str) -> str: + return "backup" if key.endswith(".backup") else "rsc" if key.endswith(".rsc") else "other" + + +async def search(device: str = "", group: str = "", kind: str = "", date_from: date | None = None, + date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]: + """Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}). + Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства. + group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе id группы. + Даты — по времени изменения объекта (UTC), включительно.""" + group_names = {g["id"]: g["name"] for g in groups.list_groups()} + device_group = {d.name: group_names.get(d.group_id) for d in devices.list_devices()} + want_group = group_names.get(int(group)) if group.isdigit() else None + + everything = await s3.list_backups(None) + stats = {"total": len(everything), "size": sum(i["size"] for i in everything)} + q = q.strip().lower() + out = [] + for item in everything: + parts = item["key"].split("/") + name = parts[1] if len(parts) >= 3 else "" + item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"])} + if device and name != device: + continue + if group == "none" and item["group"] is not None: + continue + if group.isdigit() and (want_group is None or item["group"] != want_group): + continue + if kind and item["kind"] != kind: + continue + day = item["last_modified"].date() + if (date_from and day < date_from) or (date_to and day > date_to): + continue + if q and q not in item["key"].lower(): + continue + out.append(item) + return out, stats + + +async def list_backups(device: str = "", group: str = "", kind: str = "", date_from: date | None = None, + date_to: date | None = None, q: str = "") -> list[dict]: + return (await search(device, group, kind, date_from, date_to, q))[0] diff --git a/app/services/devices.py b/app/services/devices.py new file mode 100644 index 0000000..93ffab1 --- /dev/null +++ b/app/services/devices.py @@ -0,0 +1,168 @@ +import re +from dataclasses import dataclass + +from app import security +from app.config import get_settings +from app.db import session_scope +from app.models import Device, Group +from app.ros.client import RosClient + +NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # имя попадает в ключи S3 — только безопасные символы + + +@dataclass +class Conn: + id: int + name: str + host: str + port: int + username: str + password: str + verify_tls: bool + use_tls: bool + + +def validate_name(name: str) -> None: + if not NAME_RE.match(name): + raise ValueError("Имя: латиница, цифры, '.', '_', '-' (до 64 символов)") + + +def list_devices() -> list[Device]: + with session_scope() as s: + return list(s.query(Device).order_by(Device.name)) + + +def get_device(device_id: int) -> Device: + with session_scope() as s: + d = s.get(Device, device_id) + if d is None: + raise LookupError(f"Устройство {device_id} не найдено") + return d + + +def _check_group(s, group_id: int | None) -> None: + if group_id is not None and s.get(Group, group_id) is None: + raise LookupError(f"Группа {group_id} не найдена") + + +NOTE_MAX = 500 + + +def _clean_note(note: str | None) -> str | None: + note = (note or "").strip() + if len(note) > NOTE_MAX: + raise ValueError(f"Примечание: не более {NOTE_MAX} символов") + return note or None + + +def _resolve_group(s, group_id: int | None, new_group: str | None) -> int | None: + """Группа устройства. new_group создаёт группу (или берёт существующую с таким именем) + в той же транзакции, что и устройство: при ошибке не остаётся лишней группы.""" + name = (new_group or "").strip() + if name: + if len(name) > 64: + raise ValueError("Название группы: от 1 до 64 символов") + g = s.query(Group).filter_by(name=name).first() + if g is None: + g = Group(name=name) + s.add(g) + s.flush() + return g.id + _check_group(s, group_id) + return group_id + + +def create_device(name, host, port, username, password, verify_tls=False, use_tls=True, + group_id: int | None = None, new_group: str | None = None, note: str | None = None) -> Device: + validate_name(name) + note = _clean_note(note) + with session_scope() as s: + group_id = _resolve_group(s, group_id, new_group) + if s.query(Device).filter_by(name=name).first(): + raise ValueError(f"Устройство с именем {name} уже существует") + d = Device(name=name, host=host, port=port, username=username, + password_enc=security.encrypt(password), verify_tls=verify_tls, use_tls=use_tls, + group_id=group_id, note=note) + s.add(d) + s.flush() + return d + + +def update_device(device_id: int, **fields) -> Device: + with session_scope() as s: + d = s.get(Device, device_id) + if d is None: + raise LookupError(f"Устройство {device_id} не найдено") + if fields.get("name") and fields["name"] != d.name: + validate_name(fields["name"]) + d.name = fields["name"] + for k in ("host", "port", "username", "verify_tls", "use_tls"): + if fields.get(k) is not None: + setattr(d, k, fields[k]) + if "group_id" in fields or fields.get("new_group"): # group_id=None — открепить от группы + d.group_id = _resolve_group(s, fields.get("group_id"), fields.get("new_group")) + if "note" in fields: # пустое примечание — очистить + d.note = _clean_note(fields["note"]) + if fields.get("password"): # пустой пароль = не менять + d.password_enc = security.encrypt(fields["password"]) + return d + + +def delete_device(device_id: int) -> None: + with session_scope() as s: + d = s.get(Device, device_id) + if d is None: + raise LookupError(f"Устройство {device_id} не найдено") + s.delete(d) + + +def get_conn(device_id: int) -> Conn: + d = get_device(device_id) + return Conn(d.id, d.name, d.host, d.port, d.username, + security.decrypt(d.password_enc), d.verify_tls, d.use_tls) + + +def open_client(conn: Conn) -> RosClient: + return RosClient(conn.host, conn.port, conn.username, conn.password, + conn.verify_tls, get_settings().ros_timeout, conn.use_tls, + connect_timeout=get_settings().ros_connect_timeout) + + +def has_ros_update(st: dict) -> bool: + return bool(st.get("ros_latest") and st.get("ros_installed") and st["ros_latest"] != st["ros_installed"]) + + +def has_fw_update(st: dict) -> bool: + return bool(st.get("fw_current") and st.get("fw_upgrade") and st["fw_upgrade"] != st["fw_current"]) + + +def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "", + updates: str = "", channel: str = "") -> list[Device]: + """Общий фильтр списка устройств для UI и API (по кэшу статуса). + + group: "" — все, "none" — без группы, иначе id группы. status: online/offline. + updates: ros / fw / any (есть обновление) / none (обновлений нет). + """ + q = q.strip().lower() + out = [] + for d in devs: + st = d.status + if group == "none" and d.group_id is not None: + continue + if group not in ("", "none") and str(d.group_id) != group: + continue + if q and q not in " ".join((d.name, d.host, st.get("model") or "")).lower(): + continue + if status == "online" and d.online is not True: + continue + if status == "offline" and d.online is True: + continue + ros_new, fw_new = has_ros_update(st), has_fw_update(st) + if (updates == "ros" and not ros_new) or (updates == "fw" and not fw_new): + continue + if (updates == "any" and not (ros_new or fw_new)) or (updates == "none" and (ros_new or fw_new)): + continue + if channel and st.get("channel") != channel: + continue + out.append(d) + return out diff --git a/app/services/groups.py b/app/services/groups.py new file mode 100644 index 0000000..fe9f8dc --- /dev/null +++ b/app/services/groups.py @@ -0,0 +1,68 @@ +from sqlalchemy import func, update + +from app.db import session_scope +from app.models import Device, Group + + +def _clean(name: str) -> str: + name = (name or "").strip() + if not name or len(name) > 64: + raise ValueError("Название группы: от 1 до 64 символов") + return name + + +def list_groups() -> list[dict]: + """Группы с числом устройств, по алфавиту.""" + with session_scope() as s: + counts = dict(s.query(Device.group_id, func.count(Device.id)).group_by(Device.group_id)) + return [{"id": g.id, "name": g.name, "device_count": counts.get(g.id, 0)} + for g in s.query(Group).order_by(Group.name)] + + +def get_group(group_id: int) -> Group: + with session_scope() as s: + g = s.get(Group, group_id) + if g is None: + raise LookupError(f"Группа {group_id} не найдена") + return g + + +def create_group(name: str) -> Group: + name = _clean(name) + with session_scope() as s: + if s.query(Group).filter_by(name=name).first(): + raise ValueError(f"Группа «{name}» уже существует") + g = Group(name=name) + s.add(g) + s.flush() + return g + + +def rename_group(group_id: int, name: str) -> Group: + name = _clean(name) + with session_scope() as s: + g = s.get(Group, group_id) + if g is None: + raise LookupError(f"Группа {group_id} не найдена") + if s.query(Group).filter(Group.name == name, Group.id != group_id).first(): + raise ValueError(f"Группа «{name}» уже существует") + g.name = name + return g + + +def delete_group(group_id: int) -> None: + """Удаляет группу; устройства остаются и становятся «Без группы» (FK в SQLite не включены).""" + with session_scope() as s: + g = s.get(Group, group_id) + if g is None: + raise LookupError(f"Группа {group_id} не найдена") + s.execute(update(Device).where(Device.group_id == group_id).values(group_id=None)) + s.delete(g) + + +def move_devices(device_ids: list[int], group_id: int | None) -> None: + """Переносит устройства в группу (None — «Без группы»).""" + with session_scope() as s: + if group_id is not None and s.get(Group, group_id) is None: + raise LookupError(f"Группа {group_id} не найдена") + s.execute(update(Device).where(Device.id.in_(device_ids)).values(group_id=group_id)) diff --git a/app/services/jobs.py b/app/services/jobs.py new file mode 100644 index 0000000..e34de02 --- /dev/null +++ b/app/services/jobs.py @@ -0,0 +1,81 @@ +"""Фоновые задачи: запись в таблицу jobs, параллелизм ограничен MAX_CONCURRENCY.""" +import asyncio + +from app.config import get_settings +from app.db import session_scope +from app.models import Device, Job, now +from app.services import ops + +JOB_TYPES = { + "backup": ops.run_backup, + "ros_update": ops.run_ros_update, + "fw_update": ops.run_fw_update, +} + +_tasks: set[asyncio.Task] = set() +_sem: asyncio.Semaphore | None = None + + +def _semaphore() -> asyncio.Semaphore: + global _sem + if _sem is None: + _sem = asyncio.Semaphore(get_settings().max_concurrency) + return _sem + + +def _finish(job_id: int, status: str, message: str) -> None: + with session_scope() as s: + j = s.get(Job, job_id) + j.status, j.message = status, message + if status in ("done", "failed"): + j.finished_at = now() + + +async def _run(job_id: int, job_type: str, device_id: int) -> None: + async with _semaphore(): + _finish(job_id, "running", "") + try: + _finish(job_id, "done", await JOB_TYPES[job_type](device_id)) + except Exception as e: # noqa: BLE001 — любой сбой фиксируем в задаче + _finish(job_id, "failed", str(e)) + + +def start_jobs(job_type: str, device_ids: list[int]) -> list[int]: + """Создаёт задачи для списка устройств и запускает их в фоне. Возвращает id задач.""" + if job_type not in JOB_TYPES: + raise ValueError(f"Неизвестный тип задачи: {job_type}") + job_ids = [] + with session_scope() as s: + for did in device_ids: + d = s.get(Device, did) + if d is None: + raise LookupError(f"Устройство {did} не найдено") + j = Job(device_id=did, device_name=d.name, type=job_type) + s.add(j) + s.flush() + job_ids.append((j.id, did)) + for jid, did in job_ids: + task = asyncio.create_task(_run(jid, job_type, did)) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + return [jid for jid, _ in job_ids] + + +def list_jobs(limit: int = 50) -> list[Job]: + with session_scope() as s: + return list(s.query(Job).order_by(Job.id.desc()).limit(limit)) + + +def get_job(job_id: int) -> Job: + with session_scope() as s: + j = s.get(Job, job_id) + if j is None: + raise LookupError(f"Задача {job_id} не найдена") + return j + + +def fail_stale_jobs() -> None: + """После перезапуска сервера незавершённые задачи помечаются как прерванные.""" + with session_scope() as s: + for j in s.query(Job).filter(Job.status.in_(("pending", "running"))): + j.status, j.message, j.finished_at = "failed", "Прервано перезапуском сервера", now() diff --git a/app/services/ops.py b/app/services/ops.py new file mode 100644 index 0000000..d90faac --- /dev/null +++ b/app/services/ops.py @@ -0,0 +1,124 @@ +"""Оркестрация операций: RouterOS + S3 + БД.""" +import asyncio +import json +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from app import s3 +from app.config import get_settings +from app.db import session_scope +from app.models import Backup, Device, now +from app.ros import operations as ros +from app.ros.client import RosError +from app.services import devices + + +def _save_status(device_id: int, fields: dict) -> None: + with session_scope() as s: + d = s.get(Device, device_id) + if d is not None: # устройство могли удалить во время опроса + for k, v in fields.items(): + setattr(d, k, v) + d.status_at = now() + + +async def refresh_status(device_id: int) -> None: + """Полный опрос: статус, версии и проверка обновлений. Недоступность — не исключение.""" + conn = devices.get_conn(device_id) + try: + async with devices.open_client(conn) as c: + status = await ros.get_status(c) + fields = dict(online=True, status_json=json.dumps(status), last_error=None) + except RosError as e: + fields = dict(online=False, last_error=str(e)) + _save_status(device_id, fields) + + +async def poll_device(device_id: int, full: bool = False) -> None: + """Фоновый опрос. Лёгкий (один запрос system/resource) обновляет online, uptime и версию ROS; + полный — если нужна проверка обновлений (full) либо устройство было недоступно/ещё не опрошено + (после возвращения версии могли измениться).""" + d = devices.get_device(device_id) + if full or d.online is not True or not d.status: + return await refresh_status(device_id) + try: + async with devices.open_client(devices.get_conn(device_id)) as c: + res = await c.get("system/resource") + except RosError as e: + return _save_status(device_id, dict(online=False, last_error=str(e))) + status = {**d.status, "uptime": res.get("uptime"), "ros_version": res.get("version")} + _save_status(device_id, dict(online=True, status_json=json.dumps(status), last_error=None)) + + +async def refresh_many(device_ids: list[int] | None = None) -> None: + ids = device_ids if device_ids is not None else [d.id for d in devices.list_devices()] + sem = asyncio.Semaphore(get_settings().max_concurrency) + + async def one(i: int): + async with sem: + await refresh_status(i) + + await asyncio.gather(*(one(i) for i in ids)) + + +async def run_backup(device_id: int) -> str: + """Бэкап .backup + .rsc: создать на устройстве → скачать через REST во временную папку → + загрузить в S3 от имени сервера → удалить файлы с устройства.""" + conn = devices.get_conn(device_id) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + base = f"ros_control-{stamp}" + files = { # имя на устройстве -> ключ в S3 + f"{base}.backup": f"{s3.device_prefix(conn.name)}{stamp}.backup", + f"{base}.rsc": f"{s3.device_prefix(conn.name)}{stamp}.rsc", + } + key_bin, key_rsc = files[f"{base}.backup"], files[f"{base}.rsc"] + + with session_scope() as s: + b = Backup(device_id=device_id, key_binary=key_bin, key_rsc=key_rsc) + s.add(b) + s.get(Device, device_id).last_backup_requested_at = b.requested_at = now() + s.flush() + backup_id = b.id + + status, error = "failed", "прервано" + try: + with tempfile.TemporaryDirectory(prefix="ros_control-") as tmp: + async with devices.open_client(conn) as c: + try: + await ros.create_backup_files(c, base) + for name, key in files.items(): + local = Path(tmp) / name + await ros.download_file(c, name, local) + await s3.upload_file(local, key) + finally: + for name in files: # на устройстве файлы не остаются ни при успехе, ни при сбое + try: + await ros.remove_file(c, name) + except RosError: + pass + status, error = "done", None + except Exception as e: + error = str(e) + raise + finally: + with session_scope() as s: + b = s.get(Backup, backup_id) + b.status, b.error = status, error + return f"Бэкап загружен в S3: {key_bin}, {key_rsc}" + + +async def set_channel(device_id: int, channel: str) -> None: + async with devices.open_client(devices.get_conn(device_id)) as c: + await ros.set_channel(c, channel) + await refresh_status(device_id) + + +async def run_ros_update(device_id: int) -> str: + async with devices.open_client(devices.get_conn(device_id)) as c: + return await ros.install_ros_update(c) + + +async def run_fw_update(device_id: int) -> str: + async with devices.open_client(devices.get_conn(device_id)) as c: + return await ros.upgrade_firmware(c) diff --git a/app/services/poller.py b/app/services/poller.py new file mode 100644 index 0000000..a4296cb --- /dev/null +++ b/app/services/poller.py @@ -0,0 +1,45 @@ +"""Фоновый опрос устройств: быстрое обнаружение недоступности (period = POLL_INTERVAL).""" +import asyncio +import logging +import time + +from app.config import get_settings +from app.services import devices, ops + +log = logging.getLogger("ros_control.poller") + + +async def cycle(last_full: dict[int, float]) -> None: + """Один проход по всем устройствам (параллельно, не более MAX_CONCURRENCY одновременно).""" + s = get_settings() + ids = [d.id for d in devices.list_devices()] + for gone in set(last_full) - set(ids): + del last_full[gone] + sem = asyncio.Semaphore(s.max_concurrency) + started = time.monotonic() + + async def one(device_id: int) -> None: + async with sem: + full = started - last_full.get(device_id, float("-inf")) >= s.update_check_interval + try: + await ops.poll_device(device_id, full) + except LookupError: # удалили во время цикла + return + except Exception: # noqa: BLE001 — сбой одного устройства не должен ронять опрос остальных + log.exception("poll failed: device %s", device_id) + return + if full: + last_full[device_id] = time.monotonic() + + await asyncio.gather(*(one(i) for i in ids)) + + +async def run() -> None: + last_full: dict[int, float] = {} + interval = get_settings().poll_interval + while True: + try: + await cycle(last_full) + except Exception: # noqa: BLE001 + log.exception("poll cycle failed") + await asyncio.sleep(interval) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/icons.py b/app/ui/icons.py new file mode 100644 index 0000000..1fbd1c0 --- /dev/null +++ b/app/ui/icons.py @@ -0,0 +1,15 @@ +"""Иконки интерфейса: inline SVG (stroke), общий набор с макетом.""" +ICONS = { + "search": '', + "chev": '', + "plus": '', + "refresh": '', + "more": '', + "check": '', + "up": '', + "x": '', + "archive": '', + "download": '', + "router": '', + "arrow": '', +} diff --git a/app/ui/routes.py b/app/ui/routes.py new file mode 100644 index 0000000..f41b287 --- /dev/null +++ b/app/ui/routes.py @@ -0,0 +1,379 @@ +"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API.""" +from datetime import date +from pathlib import Path +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Form, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from app import s3, security +from app.config import get_settings +from app.ros.operations import CHANNELS +from app.services import backups, devices, groups, jobs, ops +from app.ui.icons import ICONS + + +def plural(n, one: str, few: str, many: str) -> str: + """Русское склонение по числу: 1 файл, 2 файла, 5 файлов.""" + n = abs(int(n)) + if n % 10 == 1 and n % 100 != 11: + return one + if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14: + return few + return many + + +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") +templates.env.filters["dt"] = lambda v: v.strftime("%Y-%m-%d %H:%M") if v else "—" +templates.env.filters["size"] = lambda n: f"{n / 1024 / 1024:.1f} МБ" if n >= 1024 * 1024 else f"{n / 1024:.0f} КБ" +templates.env.filters["plural"] = plural +templates.env.globals["ICONS"] = ICONS + + +class LoginRequired(Exception): + pass + + +def require_login(request: Request) -> None: + if not request.session.get("user"): + raise LoginRequired() + + +router = APIRouter(include_in_schema=False) + + +def _render(request: Request, name: str, **ctx): + return templates.TemplateResponse(request, name, ctx) + + +def _is_htmx(request: Request) -> bool: + return request.headers.get("HX-Request") == "true" + + +def _done(request: Request, fallback: str = "/"): + """Успех формы: из окна (HTMX) — обновить страницу, обычной формой — редирект.""" + if _is_htmx(request): + return Response(headers={"HX-Refresh": "true"}) + return RedirectResponse(fallback, status_code=303) + + +def _opt_int(value: str) -> int | None: + return int(value) if str(value).strip().isdigit() else None + + +FILTER_FIELDS = ("group", "q", "status", "updates", "channel") # в форме и URL — с префиксом f_ +FILTER_VALUES = {"status": ("online", "offline"), "updates": ("ros", "fw", "any", "none")} + + +def _flt(params) -> dict: + """Фильтр списка устройств из query/формы (поля f_*).""" + flt = {k: str(params.get(f"f_{k}") or "").strip() for k in FILTER_FIELDS} + for k, allowed in FILTER_VALUES.items(): + if flt[k] not in allowed: + flt[k] = "" + return flt + + +def _page_url(flt: dict, **override) -> str: + """Адрес страницы устройств с фильтром (только непустые поля).""" + merged = {**flt, **override} + query = urlencode({f"f_{k}": v for k, v in merged.items() if v}) + return "/?" + query if query else "/" + + +def _visible(flt: dict): + return devices.filter_devices(devices.list_devices(), **flt) + + +def _devices_ctx(flt: dict) -> dict: + everyone = devices.list_devices() + stats = {"total": len(everyone), "online": sum(d.online is True for d in everyone), + "offline": sum(d.online is False for d in everyone)} + return {"devices": devices.filter_devices(everyone, **flt), "channels": CHANNELS, "filtered": any(flt.values()), + "total": len(everyone), "stats": stats, + "group_names": {g["id"]: g["name"] for g in groups.list_groups()}} + + +def _tabs(flt: dict) -> list[dict]: + """Вкладки групп: «Все», «Без группы» и каждая группа со счётчиком устройств.""" + all_devices = devices.list_devices() + glist = groups.list_groups() + items = [("Все", "", len(all_devices)), + ("Без группы", "none", sum(1 for d in all_devices if d.group_id is None))] + items += [(g["name"], str(g["id"]), g["device_count"]) for g in glist] + return [{"label": label, "count": n, "href": _page_url(flt, group=value), "active": flt["group"] == value} + for label, value, n in items] + + +# --- вход / выход --- + +@router.get("/login", response_class=HTMLResponse) +async def login_form(request: Request): + return _render(request, "login.html", error=None) + + +@router.post("/login") +async def login(request: Request, username: str = Form(), password: str = Form()): + if not security.check_admin(username, password): + return _render(request, "login.html", error="Неверный логин или пароль") + request.session["user"] = username + return RedirectResponse("/", status_code=303) + + +@router.post("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +# --- дашборд --- + +@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def dashboard(request: Request): + flt = _flt(request.query_params) + return _render(request, "dashboard.html", section="devices", jobs=jobs.list_jobs(15), flt=flt, tabs=_tabs(flt), + groups=groups.list_groups(), page_url=_page_url(flt), + flt_active=sum(1 for k in ("q", "status", "updates", "channel") if flt[k]), **_devices_ctx(flt)) + + +@router.get("/ui/devices", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def devices_table(request: Request): + flt = _flt(request.query_params) + resp = _render(request, "_devices.html", oob=True, **_devices_ctx(flt)) + if request.query_params.get("poll") != "1": # автообновление не должно засорять историю браузера + resp.headers["HX-Push-Url"] = _page_url(flt) # фильтр переживает перезагрузку страницы + return resp + + +@router.get("/ui/jobs", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def jobs_table(request: Request): + return _render(request, "_jobs.html", jobs=jobs.list_jobs(15)) + + +@router.post("/ui/refresh", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def refresh(request: Request, device_ids: list[int] = Form(default=[])): + flt = _flt(await request.form()) + # без выбора обновляем все устройства текущего представления (группа + фильтры) + await ops.refresh_many(device_ids or [d.id for d in _visible(flt)]) + return _render(request, "_devices.html", oob=True, **_devices_ctx(flt)) + + +@router.post("/ui/batch/{action}", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def batch(request: Request, action: str, device_ids: list[int] = Form(default=[]), + channel: str = Form(default="")): + if not device_ids: + return _render(request, "_jobs.html", jobs=jobs.list_jobs(15), flash="Выберите устройства") + if action == "channel": + for i in device_ids: + await ops.set_channel(i, channel) + return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form()))) + jobs.start_jobs(action, device_ids) + return _render(request, "_jobs.html", jobs=jobs.list_jobs(15)) + + +@router.post("/ui/devices/{device_id}/{action}", response_class=HTMLResponse, + dependencies=[Depends(require_login)]) +async def device_action(request: Request, device_id: int, action: str, channel: str = Form(default="")): + """Действие над одним устройством (пункты меню «⋯» в строке таблицы).""" + if action == "refresh": + await ops.refresh_status(device_id) + elif action == "channel": + await ops.set_channel(device_id, channel) + else: + jobs.start_jobs(action, [device_id]) + return _render(request, "_jobs.html", jobs=jobs.list_jobs(15)) + return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form()))) + + +@router.post("/ui/move", dependencies=[Depends(require_login)]) +async def move(device_ids: list[int] = Form(default=[]), group_id: str = Form(""), next: str = Form("/")): + """Перенос отмеченных устройств в группу (обычный POST + редирект: счётчики вкладок обновляются).""" + if device_ids: + groups.move_devices(device_ids, _opt_int(group_id)) + return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/", status_code=303) + + +# --- окна (dialog) и формы: одна разметка для окна и запасной страницы --- + +def _device_values(device=None, **form) -> dict: + """Значения полей формы устройства: из отправленной формы (при ошибке), из устройства или значения по умолчанию.""" + if form: + return form + if device is not None: + return dict(name=device.name, host=device.host, port=device.port, username=device.username, + group_id=str(device.group_id or ""), new_group="", use_tls=device.use_tls, + verify_tls=device.verify_tls, note=device.note or "") + return dict(name="", host="", port=443, username="", group_id="", new_group="", use_tls=True, verify_tls=False, note="") + + +def _device_form(request: Request, device, v: dict, error: str | None = None): + ctx = dict(device=device, v=v, error=error, groups=groups.list_groups(), section="devices") + if _is_htmx(request): + return _render(request, "_device_form.html", in_modal=True, **ctx) + return _render(request, "device_form.html", in_modal=False, **ctx) + + +def _group_form(request: Request, group, name: str, error: str | None = None): + ctx = dict(group=group, gname=name, error=error) + return _render(request, "_group_form.html", in_modal=_is_htmx(request), **ctx) + + +@router.get("/ui/dialog/device", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def dialog_device_new(request: Request, group: str = ""): + v = _device_values() + v["group_id"] = group if group.isdigit() else "" + return _render(request, "_device_form.html", in_modal=True, device=None, v=v, error=None, groups=groups.list_groups()) + + +@router.get("/ui/dialog/device/{device_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def dialog_device_edit(request: Request, device_id: int): + d = devices.get_device(device_id) + return _render(request, "_device_form.html", in_modal=True, device=d, v=_device_values(d), error=None, + groups=groups.list_groups()) + + +@router.get("/ui/dialog/group", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def dialog_group_new(request: Request): + return _render(request, "_group_form.html", in_modal=True, group=None, gname="", error=None) + + +@router.get("/ui/dialog/group/{group_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def dialog_group_edit(request: Request, group_id: int): + g = groups.get_group(group_id) + return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None) + + +# --- группы --- + +@router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def groups_page(request: Request, error: str = ""): + everyone = devices.list_devices() + return _render(request, "groups.html", section="groups", groups=groups.list_groups(), error=error, + total=len(everyone), ungrouped=sum(1 for d in everyone if d.group_id is None)) + + +def _group_action(request: Request, fn, *args, name: str = "", group=None): + try: + fn(*args) + except ValueError as e: + if _is_htmx(request): # ошибка показывается в окне + return _group_form(request, group, name, str(e)) + return RedirectResponse("/groups?" + urlencode({"error": str(e)}), status_code=303) + return _done(request, "/groups") + + +@router.post("/groups/new", dependencies=[Depends(require_login)]) +async def group_create(request: Request, name: str = Form()): + return _group_action(request, groups.create_group, name, name=name) + + +@router.post("/groups/{group_id}/rename", dependencies=[Depends(require_login)]) +async def group_rename(request: Request, group_id: int, name: str = Form()): + return _group_action(request, groups.rename_group, group_id, name, name=name, group=groups.get_group(group_id)) + + +@router.post("/groups/{group_id}/delete", dependencies=[Depends(require_login)]) +async def group_delete(request: Request, group_id: int): + return _group_action(request, groups.delete_group, group_id) + + +# --- управление списком устройств --- + +def _group_choice(group_id: str, new_group: str) -> tuple[int | None, str | None]: + """Выбор в списке «Группа»: id, «без группы» или «Новая группа…» (тогда нужно название).""" + if group_id == "__new__": + if not new_group.strip(): + raise ValueError("Введите название новой группы") + return None, new_group.strip() + return _opt_int(group_id), None + + +@router.get("/devices/new", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def device_new(request: Request, group: str = ""): + v = _device_values() + v["group_id"] = group if group.isdigit() else "" + return _device_form(request, None, v) + + +@router.post("/devices/new", dependencies=[Depends(require_login)]) +async def device_create(request: Request, name: str = Form(), host: str = Form(), + port: int = Form(443), username: str = Form(), password: str = Form(), + verify_tls: bool = Form(False), use_tls: bool = Form(False), + group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")): + v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group, + use_tls=use_tls, verify_tls=verify_tls, note=note) + try: + gid, ng = _group_choice(group_id, new_group) + d = devices.create_device(name, host, port, username, password, verify_tls, use_tls, gid, ng, note) + except ValueError as e: + return _device_form(request, None, v, str(e)) + await ops.refresh_status(d.id) + return _done(request) + + +@router.get("/devices/{device_id}/edit", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def device_edit(request: Request, device_id: int): + d = devices.get_device(device_id) + return _device_form(request, d, _device_values(d)) + + +@router.post("/devices/{device_id}/edit", dependencies=[Depends(require_login)]) +async def device_update(request: Request, device_id: int, name: str = Form(), host: str = Form(), + port: int = Form(443), username: str = Form(), password: str = Form(""), + verify_tls: bool = Form(False), use_tls: bool = Form(False), + group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")): + v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group, + use_tls=use_tls, verify_tls=verify_tls, note=note) + try: + gid, ng = _group_choice(group_id, new_group) + devices.update_device(device_id, name=name, host=host, port=port, username=username, password=password, + verify_tls=verify_tls, use_tls=use_tls, group_id=gid, new_group=ng, note=note) + except ValueError as e: + return _device_form(request, devices.get_device(device_id), v, str(e)) + return _done(request) + + +@router.post("/devices/{device_id}/delete", dependencies=[Depends(require_login)]) +async def device_delete(request: Request, device_id: int): + devices.delete_device(device_id) + return _done(request) + + +# --- резервные копии в бакете --- + +def _opt_date(value: str) -> date | None: + try: + return date.fromisoformat(value) + except ValueError: + return None + + +@router.get("/backups", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +async def backups_page(request: Request, device: str = "", group: str = "", kind: str = "", + date_from: str = "", date_to: str = "", q: str = ""): + kind = kind if kind in ("backup", "rsc") else "" + items, stats, error = [], {"total": 0, "size": 0}, None + try: + items, stats = await backups.search(device, group, kind, _opt_date(date_from), _opt_date(date_to), q) + except Exception as e: # noqa: BLE001 — показываем ошибку S3 на странице + error = str(e) + flt = dict(device=device, group=group, kind=kind, date_from=date_from, date_to=date_to, q=q) + return _render(request, "backups.html", section="backups", items=items, stats=stats, error=error, flt=flt, + flt_active=sum(1 for v in flt.values() if v), bucket=get_settings().s3_bucket, + devices=devices.list_devices(), groups=groups.list_groups()) + + +@router.get("/backups/download", dependencies=[Depends(require_login)]) +async def backup_download(key: str): + if not s3.key_allowed(key): + raise ValueError("Недопустимый ключ") + return RedirectResponse(s3.presign_get(key)) + + +@router.post("/backups/delete", dependencies=[Depends(require_login)]) +async def backup_delete(key: str = Form()): + if not s3.key_allowed(key): + raise ValueError("Недопустимый ключ") + await s3.delete_object(key) + return RedirectResponse("/backups", status_code=303) diff --git a/app/ui/static/app.js b/app/ui/static/app.js new file mode 100644 index 0000000..5480d3f --- /dev/null +++ b/app/ui/static/app.js @@ -0,0 +1,105 @@ +// ros_control UI: выбор строк, меню, окна, состояние фильтров. Без внешних библиотек (кроме htmx). +(() => { + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => [...r.querySelectorAll(s)]; + + // --- выбор строк: панель фильтров ⇄ панель действий над выбранными --- + function syncSelection() { + const boxes = $$("input[name=device_ids]"); + const n = boxes.filter((b) => b.checked).length; + boxes.forEach((b) => b.closest("tr")?.classList.toggle("sel", b.checked)); + const all = $("#check-all"); + if (all) { all.checked = boxes.length > 0 && n === boxes.length; all.indeterminate = n > 0 && n < boxes.length; } + const bulk = $("#tools-bulk"), filters = $("#tools-filters"); + if (bulk && filters) { bulk.hidden = n === 0; filters.hidden = n > 0; } + const cnt = $("#sel-count"); if (cnt) cnt.textContent = n; + } + + // --- фильтры устройств: подсветка включённых полей и кнопка «Сбросить (N)» --- + function syncFilters() { + const form = $("#dev-filters"); if (!form) return; + let active = 0; + $$("select.field", form).forEach((s) => { const on = s.value !== ""; s.classList.toggle("on", on); active += on; }); + if ($("input[name=f_q]", form)?.value.trim()) active += 1; + const reset = $("#filters-reset", form); + if (reset) { reset.hidden = active === 0; reset.textContent = `Сбросить (${active})`; } + } + + // Выпадающие списки позиционируются от кнопки в координатах окна: их не обрезает прокручиваемая + // таблица, а внизу экрана список раскрывается вверх. + function placeMenu(menu) { + const panel = $(".menu-panel", menu), btn = $("summary", menu); + if (!panel || !btn) return; + panel.style.position = "fixed"; panel.style.top = "0"; panel.style.left = "0"; panel.style.right = "auto"; + const b = btn.getBoundingClientRect(), w = panel.offsetWidth, h = panel.offsetHeight, gap = 4, edge = 8; + let left = menu.classList.contains("right") ? b.right - w : b.left; + left = Math.max(edge, Math.min(left, window.innerWidth - w - edge)); + const below = b.bottom + gap, above = b.top - gap - h; + panel.style.left = `${left}px`; + panel.style.top = `${below + h > window.innerHeight - edge && above >= edge ? above : below}px`; + } + document.addEventListener("toggle", (e) => { if (e.target.matches?.("details.menu[open]")) placeMenu(e.target); }, true); + // при прокрутке/изменении размера список следует за кнопкой; если кнопка ушла за пределы окна — закрывается + function reflowMenu(e) { + if (e?.target?.closest?.(".menu-panel")) return; + const menu = $("details.menu[open]"); if (!menu) return; + const r = $("summary", menu).getBoundingClientRect(); + if (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth) menu.removeAttribute("open"); + else placeMenu(menu); + } + window.addEventListener("scroll", reflowMenu, true); + window.addEventListener("resize", reflowMenu); + + const closeMenus = (keep) => $$("details.menu[open]").forEach((m) => { if (m !== keep) m.removeAttribute("open"); }); + + document.addEventListener("change", (e) => { + const t = e.target; + if (t.matches("input[name=device_ids]")) syncSelection(); + if (t.id === "check-all") { $$("input[name=device_ids]").forEach((b) => (b.checked = t.checked)); syncSelection(); } + if (t.closest("#dev-filters")) syncFilters(); + // «Новая группа…» в списке групп окна устройства + if (t.matches("select[name=group_id][data-newgroup]")) $("#new-group-field").hidden = t.value !== "__new__"; + // предупреждение о HTTP + if (t.matches("input[name=use_tls]")) $("#http-note").hidden = t.checked; + }); + document.addEventListener("input", (e) => { if (e.target.closest("#dev-filters")) syncFilters(); }); + + document.addEventListener("click", (e) => { + const t = e.target; + if (t.closest("#clear-sel")) { $$("input[name=device_ids]").forEach((b) => (b.checked = false)); syncSelection(); } + // меню: закрывается по клику вне и после выбора пункта; открыто не более одного + if (t.closest("details.sub > summary")) return; // раскрытие подменю («Сменить канал…») меню не закрывает + const menu = t.closest("details.menu"); + closeMenus(t.closest(".menu-panel") ? null : menu); + // окно: закрытие по кнопке и по клику на подложку + const modal = $("#modal"); + if (modal && (t.closest("[data-close]") || t === modal)) modal.close(); + }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(null); }); + + // окно открывается, когда HTMX положил в него содержимое + document.addEventListener("htmx:afterSwap", (e) => { + const modal = $("#modal"); + if (e.detail.target.id === "modal-body") { + if (modal && !modal.open) modal.showModal(); + $("#modal-body [autofocus], #modal-body input:not([type=hidden])")?.focus(); + const sel = $("select[name=group_id][data-newgroup]"); if (sel) $("#new-group-field").hidden = sel.value !== "__new__"; + } + syncSelection(); syncFilters(); + }); + + // --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат --- + const REFRESH_MS = 15000; + function autoRefresh() { + const form = $("#dev-filters"), target = $("#devices"); + if (!form || !target || document.hidden) return; + const busy = $$("input[name=device_ids]:checked").length || $("details[open]") || $("#modal")?.open || $("#dev-card .htmx-request"); + if (busy || form.contains(document.activeElement)) return; // не мешаем работе пользователя + const params = new URLSearchParams(new FormData(form)); params.set("poll", "1"); + htmx.ajax("GET", `/ui/devices?${params}`, { target: "#devices", swap: "innerHTML" }); + } + setInterval(autoRefresh, REFRESH_MS); + + const init = () => { syncSelection(); syncFilters(); }; + document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init(); +})(); diff --git a/app/ui/static/fonts/OFL.txt b/app/ui/static/fonts/OFL.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/app/ui/static/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/ui/static/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 b/app/ui/static/fonts/ibm-plex-mono-cyrillic-400-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..20e72b01579543fe0cb4aed955a146b6b949709f GIT binary patch literal 8356 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 literal 0 HcmV?d00001 diff --git a/app/ui/static/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 b/app/ui/static/fonts/ibm-plex-sans-cyrillic-500-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..189f9dcfebb44722406f2c8e7e06e6f194154ab9 GIT binary patch literal 15000 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 literal 0 HcmV?d00001 diff --git a/app/ui/static/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 b/app/ui/static/fonts/ibm-plex-sans-cyrillic-600-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2c315c4771fababf16526d5190cefa26c4e85f7c GIT binary patch literal 15040 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 W!UW;Hvt2PP?%PobSYccN0000gCRuv` literal 0 HcmV?d00001 diff --git a/app/ui/static/fonts/ibm-plex-sans-latin-400-normal.woff2 b/app/ui/static/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f0ee65dd7f3ed64dc8d674e5c6007a0e7761bd57 GIT binary patch literal 22588 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 R;$T5nL-p?z%LdxSxC2frlW_n5 literal 0 HcmV?d00001 diff --git a/app/ui/static/fonts/ibm-plex-sans-latin-500-normal.woff2 b/app/ui/static/fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6d5527eef47ace16b08cf9932050b83858995e62 GIT binary patch literal 24184 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 r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","

");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/app/ui/static/style.css b/app/ui/static/style.css new file mode 100644 index 0000000..320473c --- /dev/null +++ b/app/ui/static/style.css @@ -0,0 +1,202 @@ +/* ros_control UI — перенос утверждённого макета. Значения (размеры, радиусы, цвета) взяты из макета 1:1. + Формы: контролы h36 r8 · метки h22 r6 · панели/карточки r12. Шрифты — IBM Plex (локально, OFL). */ + +@font-face { font-family: "IBM Plex Sans"; font-weight: 400; font-display: swap; src: url(fonts/ibm-plex-sans-cyrillic-400-normal.woff2) format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: "IBM Plex Sans"; font-weight: 400; font-display: swap; src: url(fonts/ibm-plex-sans-latin-400-normal.woff2) format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +@font-face { font-family: "IBM Plex Sans"; font-weight: 500; font-display: swap; src: url(fonts/ibm-plex-sans-cyrillic-500-normal.woff2) format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: "IBM Plex Sans"; font-weight: 500; font-display: swap; src: url(fonts/ibm-plex-sans-latin-500-normal.woff2) format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +@font-face { font-family: "IBM Plex Sans"; font-weight: 600; font-display: swap; src: url(fonts/ibm-plex-sans-cyrillic-600-normal.woff2) format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: "IBM Plex Sans"; font-weight: 600; font-display: swap; src: url(fonts/ibm-plex-sans-latin-600-normal.woff2) format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +@font-face { font-family: "IBM Plex Mono"; font-weight: 400; font-display: swap; src: url(fonts/ibm-plex-mono-cyrillic-400-normal.woff2) format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: "IBM Plex Mono"; font-weight: 400; font-display: swap; src: url(fonts/ibm-plex-mono-latin-400-normal.woff2) format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +@font-face { font-family: "IBM Plex Mono"; font-weight: 500; font-display: swap; src: url(fonts/ibm-plex-mono-cyrillic-500-normal.woff2) format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: "IBM Plex Mono"; font-weight: 500; font-display: swap; src: url(fonts/ibm-plex-mono-latin-500-normal.woff2) format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +:root { + --font: "IBM Plex Sans", system-ui, sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; + --bg: #f4f6f9; --surf: #ffffff; --head: #f8fafc; --sel: #f3f7ff; + --line: #dde3ea; --line2: #c5ced9; + --text: #18212f; --muted: #58657a; --faint: #8a95a5; + --acc: #2450c8; --acc-solid: #2450c8; --acc50: #eaf0ff; --acc100: #d5e2ff; --accd: #1e449e; + --ok-t: #14683a; --ok-b: #e3f4ea; --warn-t: #7c4a00; --warn-b: #fdf0d5; --bad-t: #a32020; --bad-b: #fbe6e6; + --neu-t: #3c4859; --neu-b: #eef1f5; + --dot-on: #1f8a4c; --dot-off: #c62828; --dot-unk: #8a95a5; + --shadow-menu: 0 8px 24px rgba(20, 33, 60, .14); --shadow-modal: 0 16px 48px rgba(20, 33, 60, .25); + --backdrop: rgba(24, 33, 47, .45); --disabled-bg: #f4f6f9; --disabled-t: #8a95a5; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f141b; --surf: #171e27; --head: #1b2430; --sel: #1a2540; + --line: #2a3441; --line2: #3a4759; + --text: #e6ebf2; --muted: #97a3b6; --faint: #7a8699; + --acc: #7ea1ff; --acc-solid: #3d68de; --acc50: #1c2a4a; --acc100: #26385f; --accd: #a9c1ff; + --ok-t: #6fd39a; --ok-b: #12301f; --warn-t: #f0b04a; --warn-b: #3a2a0c; --bad-t: #ff8f8f; --bad-b: #3b1616; + --neu-t: #b6c0cf; --neu-b: #232c39; + --dot-on: #3fbf78; --dot-off: #ff6b6b; --dot-unk: #7a8699; + --shadow-menu: 0 8px 24px rgba(0, 0, 0, .5); --shadow-modal: 0 16px 48px rgba(0, 0, 0, .6); + --backdrop: rgba(0, 0, 0, .6); --disabled-bg: #1b2430; --disabled-t: #6b7789; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); font: 400 14px/1.4 var(--font); } +a { color: var(--acc); text-decoration: none; } +h1, h2 { margin: 0; } +[hidden] { display: none !important; } +:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; } +.grow { flex: 1; } +.muted { color: var(--muted); } +.mono { font: 400 13px/1.2 var(--mono); } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.ico { flex: none; } + +/* ---------- 1 · Приложение ---------- */ +.app-header { height: 56px; display: flex; align-items: center; gap: 32px; padding: 0 24px; background: var(--surf); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: 10px; font: 600 16px/1 var(--font); color: var(--text); } +.brand-logo { width: 28px; height: 28px; border-radius: 8px; background: var(--acc-solid); color: #fff; display: flex; align-items: center; justify-content: center; } +.nav { display: flex; height: 100%; gap: 4px; } +.nav a { display: flex; align-items: center; height: 100%; padding: 0 14px; font: 500 14px/1 var(--font); color: var(--muted); border-bottom: 2px solid transparent; } +.nav a.on { color: var(--text); border-bottom-color: var(--acc); } +.user { color: var(--muted); } +.container { max-width: 1600px; margin: 0 auto; padding: 0 24px 48px; } + +/* ---------- 2 · Страница ---------- */ +.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin: 24px 0 16px; gap: 16px; } +.page-head h1 { font: 600 24px/1.2 var(--font); } +.page-head .sub { margin-top: 4px; color: var(--muted); } +.page-head .actions { display: flex; gap: 8px; } + +/* ---------- Кнопки: h36 · r8 · 14/500 ---------- */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; margin: 0; padding: 0 14px; border-radius: 8px; + border: 1px solid var(--line2); background: var(--surf); color: var(--text); font: 500 14px/1 var(--font); white-space: nowrap; cursor: pointer; } +.btn:hover { border-color: var(--acc); } +.btn.primary { background: var(--acc-solid); border-color: var(--acc-solid); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { background: transparent; border-color: transparent; color: var(--acc); padding: 0 10px; } +.btn.ghost-n { background: transparent; border-color: transparent; color: var(--muted); padding: 0 10px; } +.btn.ghost:hover, .btn.ghost-n:hover { background: var(--acc50); border-color: transparent; } +.btn.icon { width: 36px; padding: 0; } +.btn.danger { color: var(--bad-t); } +.btn:disabled { background: var(--disabled-bg); color: var(--disabled-t); border-color: var(--line); cursor: not-allowed; } +summary.btn { list-style: none; } +summary.btn::-webkit-details-marker { display: none; } + +/* ---------- Поля: та же форма, что у кнопок ---------- */ +.field, .input { height: 36px; margin: 0; border-radius: 8px; border: 1px solid var(--line2); background-color: var(--surf); color: var(--text); font: 400 14px/1 var(--font); } +.input { width: 100%; padding: 0 12px; } +textarea.input { height: 72px; padding: 10px 12px; font-family: var(--font); line-height: 1.4; resize: vertical; } +select.field { appearance: none; -webkit-appearance: none; padding: 0 36px 0 14px; font-weight: 500; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2358657a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 12px center; } +select.field.on { background-color: var(--acc50); border-color: var(--acc); color: var(--accd); } +select.field option { color: var(--text); background: var(--surf); font-weight: 400; } +.search { position: relative; display: inline-flex; align-items: center; flex: none; } +.search .ico { position: absolute; left: 12px; color: var(--muted); pointer-events: none; } +.search input { height: 36px; width: 280px; padding: 0 12px 0 36px; border-radius: 8px; border: 1px solid var(--line2); background: var(--surf); color: var(--text); font: 400 14px/1 var(--font); } +.search input::placeholder { color: var(--faint); } +.datefield { display: inline-flex; align-items: center; gap: 8px; height: 36px; width: 170px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--line2); background: var(--surf); color: var(--muted); font: 400 14px/1 var(--font); } +.datefield input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); font: 400 13px/1 var(--mono); padding: 0; } +.datefield input::-webkit-calendar-picker-indicator { opacity: .55; cursor: pointer; } + +/* ---------- Метки: h22 · r6 ---------- */ +.badge { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 8px; border-radius: 6px; background: var(--neu-b); color: var(--neu-t); font: 500 12px/1 var(--font); white-space: nowrap; } +.badge.warn { background: var(--warn-b); color: var(--warn-t); } +.badge.ok { background: var(--ok-b); color: var(--ok-t); } +.badge.bad { background: var(--bad-b); color: var(--bad-t); } +.badge.run { background: var(--acc50); color: var(--accd); } +.okmark { display: inline-flex; align-items: center; gap: 4px; color: var(--ok-t); font: 500 13px/1 var(--font); } +.dash { color: var(--faint); } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--dot-unk); } +.dot.on { background: var(--dot-on); } .dot.off { background: var(--dot-off); } + +/* ---------- 3 · Область: вкладки-подчёркивание ---------- */ +.tabs { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; border-bottom: 1px solid var(--line); } +.tab { padding: 10px 2px 12px; margin-bottom: -1px; font: 500 14px/1 var(--font); color: var(--muted); border-bottom: 2px solid transparent; } +.tab:hover { color: var(--text); } +.tab.on { font-weight: 600; color: var(--text); border-bottom-color: var(--acc); } +.tab .count { margin-left: 2px; font-weight: 400; color: var(--muted); } +.tab-link { display: inline-flex; align-items: center; gap: 6px; padding-bottom: 10px; font: 500 14px/1 var(--font); color: var(--acc); } + +/* ---------- Карточка, 4 · инструменты таблицы ---------- */ +.card { background: var(--surf); border: 1px solid var(--line); border-radius: 12px; } +.tools { display: flex; align-items: center; gap: 8px; height: 64px; padding: 0 16px; } +.tools.bulk { background: var(--acc50); border-radius: 12px 12px 0 0; } +.tools form { display: contents; } +.sel-count { display: inline-flex; align-items: center; gap: 4px; margin-right: 8px; font: 600 14px/1 var(--font); color: var(--accd); } +.busy { opacity: 0; color: var(--muted); } .htmx-request .busy, .busy.htmx-request { opacity: 1; } + +/* ---------- 5 · Данные: таблица ---------- */ +table.grid { width: 100%; border-collapse: collapse; table-layout: fixed; } +.grid th { height: 40px; padding: 0 6px; text-align: left; font: 600 12px/1 var(--font); color: var(--muted); background: var(--head); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.grid td { height: 60px; padding: 0 6px; border-bottom: 1px solid var(--line); vertical-align: middle; font: 400 14px/1.3 var(--font); } +.grid th:first-child, .grid td:first-child { padding-left: 16px; } +.grid th:last-child, .grid td:last-child { padding-right: 16px; } +.grid tr.sel td { background: var(--sel); } +.grid input[type=checkbox], .modal input[type=checkbox] { width: 16px; height: 16px; margin: 0; accent-color: var(--acc-solid); } +.dev { display: flex; align-items: center; gap: 10px; min-width: 0; } +.dev-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dev-link { color: inherit; } +.dev-link:hover { color: var(--acc); text-decoration: underline; } +.dev-sub { margin-top: 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dev-sub.bad { color: var(--bad-t); } +.c-mono { font: 400 13px/1.2 var(--mono); } +.c-small { font-size: 13px; color: var(--muted); } +.c-menu { text-align: right; } +.tfoot { display: flex; align-items: center; height: 44px; padding: 0 16px; font: 400 13px/1 var(--font); color: var(--muted); } +.empty { height: 88px; text-align: center; color: var(--muted); } +.table-wrap { overflow-x: auto; } +@media (min-width: 1360px) { .table-wrap { overflow: visible; } } +.grid.jobs td { height: 48px; } +.grid.files td { height: 52px; } +.grid.groups td { height: 56px; } +.section-head { display: flex; align-items: center; gap: 12px; margin: 32px 0 12px; } +.section-head h2 { font: 600 16px/1.2 var(--font); } +.msg { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } +.err { color: var(--bad-t); } + +/* ---------- Меню «⋯» и выпадающие списки ---------- */ +.menu { position: relative; display: inline-flex; } +.menu-panel { position: absolute; top: 40px; left: 0; z-index: 30; width: 250px; padding: 6px; text-align: left; background: var(--surf); + border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow-menu); } +.menu.right .menu-panel { left: auto; right: 0; } +.w200 { width: 200px; } .w270 { width: 270px; } .w330 { width: 330px; } +.mi { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; margin: 0; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; + color: var(--text); text-align: left; font: 500 14px/1.3 var(--font); cursor: pointer; } +.mi small { font: 400 12px/1.3 var(--font); color: var(--muted); } +.mi:hover { background: var(--acc50); } +.mi.warn { color: var(--warn-t); } .mi.bad { color: var(--bad-t); } +.mi.on { flex-direction: row; align-items: center; gap: 6px; } +.mi.on::before { content: "✓"; } +summary.mi { list-style: none; } summary.mi::-webkit-details-marker { display: none; } +.menu-panel hr { height: 1px; margin: 6px 0; border: 0; background: var(--line); } +details.sub .sub-list { padding-left: 10px; } + +/* ---------- Окна ---------- */ +dialog.modal { width: 560px; max-width: calc(100vw - 32px); padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surf); color: var(--text); box-shadow: var(--shadow-modal); } +dialog.modal::backdrop { background: var(--backdrop); } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 0 24px; } +.modal-head h2 { font: 600 18px/1.2 var(--font); } +.modal-body { display: flex; flex-direction: column; gap: 16px; padding: 16px 24px 24px; } +.modal-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--line); } +.form-row { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; } +.form-row.host { grid-template-columns: 1fr 110px; } +.lbl { display: flex; flex-direction: column; gap: 6px; font: 500 13px/1.3 var(--font); } +.lbl small { font-size: inherit; font-weight: 400; color: var(--muted); } +.check { display: flex; align-items: flex-start; gap: 10px; font: 500 14px/1.3 var(--font); } +.check small { display: block; font-weight: 400; font-size: 12px; color: var(--muted); } +.note { padding: 10px 12px; border-radius: 8px; background: var(--warn-b); color: var(--warn-t); font: 400 13px/1.4 var(--font); } +.form-err { padding: 10px 12px; border-radius: 8px; background: var(--bad-b); color: var(--bad-t); font: 500 13px/1.4 var(--font); } +.page-card { max-width: 560px; margin: 24px auto; } + +/* ---------- Вход ---------- */ +.login { max-width: 400px; margin: 96px auto 0; } +.login h1 { font: 600 24px/1.2 var(--font); margin-bottom: 16px; } + +/* таблица в карточке без собственной верхней/нижней границы (её даёт карточка) */ +.card > table.grid:first-child thead th { border-top: 0; } +.card > table.grid:first-child thead th:first-child { border-top-left-radius: 11px; } +.card > table.grid:first-child thead th:last-child { border-top-right-radius: 11px; } +.card > table.grid:last-child tbody tr:last-child td { border-bottom: 0; } diff --git a/app/ui/templates/_dev_sub.html b/app/ui/templates/_dev_sub.html new file mode 100644 index 0000000..375ba72 --- /dev/null +++ b/app/ui/templates/_dev_sub.html @@ -0,0 +1 @@ +{{ stats.total }} {{ stats.total|plural("устройство", "устройства", "устройств") }} · {{ stats.online }} online · {{ stats.offline }} offline \ No newline at end of file diff --git a/app/ui/templates/_device_form.html b/app/ui/templates/_device_form.html new file mode 100644 index 0000000..a0c6b79 --- /dev/null +++ b/app/ui/templates/_device_form.html @@ -0,0 +1,51 @@ +{# Форма устройства: одна разметка для окна (in_modal) и запасной страницы. v — значения полей. #} +{% import "_ui.html" as ui %} +{% set edit = device is not none %} +{% set action = ('/devices/' ~ device.id ~ '/edit') if edit else '/devices/new' %} +
+ + +
+ {% if edit %} + {% if in_modal %} + {% else %}{% endif %} + {% endif %} + + {% if in_modal %}{% else %}Отмена{% endif %} + +
+
diff --git a/app/ui/templates/_devices.html b/app/ui/templates/_devices.html new file mode 100644 index 0000000..97ad421 --- /dev/null +++ b/app/ui/templates/_devices.html @@ -0,0 +1,75 @@ +{% import "_ui.html" as ui %} +{% if oob %}
{% include "_dev_sub.html" %}
{% endif %} +
+ + + + + + + + + + {% for d in devices %} + {% set st = d.status %}{% set down = d.online is false %} + {% set ros_new = st.ros_latest and st.ros_installed and st.ros_latest != st.ros_installed %} + {% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %} + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
УстройствоГруппаКаналROSUpgrade ROSFWUpgrade FWUptimeЗапрос бэкапа (UTC)Действия
+
+ +
+ + {% if down %}
Нет соединения
+ {% else %}
{{ d.host }}:{{ d.port }}{% if st.model %} · {{ st.model }}{% endif %}
{% endif %} +
+
+
{{ group_names.get(d.group_id) or "—" }}{% if down %}—{% else %}{{ st.channel or "—" }}{% endif %}{% if down %}—{% else %}{{ st.ros_installed or st.ros_version or "—" }}{% endif %} + {% if down or not (st.ros_latest and st.ros_installed) %}— + {% elif ros_new %}{{ ui.icon("up", 12, 2.2) }}{{ st.ros_latest }} + {% else %}{{ ui.icon("check", 14, 2.2) }}актуально{% endif %} + {% if down %}—{% else %}{{ st.fw_current or "—" }}{% endif %} + {% if down or not (st.fw_current and st.fw_upgrade) %}— + {% elif fw_new %}{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }} + {% else %}{{ ui.icon("check", 14, 2.2) }}актуально{% endif %} + {% if down %}—{% else %}{{ st.uptime or "—" }}{% endif %}{{ d.last_backup_requested_at|dt }} + +
{{ "Нет устройств, подходящих под фильтр." if filtered else "Устройств пока нет. Добавьте первое." }}
+
+
Показано {{ devices|length }} из {{ total }} {{ total|plural("устройства", "устройств", "устройств") }}
diff --git a/app/ui/templates/_group_form.html b/app/ui/templates/_group_form.html new file mode 100644 index 0000000..c91f592 --- /dev/null +++ b/app/ui/templates/_group_form.html @@ -0,0 +1,18 @@ +{% import "_ui.html" as ui %} +{% set edit = group is not none %} +{% set action = ('/groups/' ~ group.id ~ '/rename') if edit else '/groups/new' %} +
+ + +
+ + {% if in_modal %}{% else %}Отмена{% endif %} + +
+
diff --git a/app/ui/templates/_jobs.html b/app/ui/templates/_jobs.html new file mode 100644 index 0000000..87567bb --- /dev/null +++ b/app/ui/templates/_jobs.html @@ -0,0 +1,31 @@ +{% import "_ui.html" as ui %} +{% set running = jobs|selectattr("status", "in", ["pending", "running"])|list|length %} +{% set done = jobs|selectattr("status", "equalto", "done")|list|length %} +{% set failed = jobs|selectattr("status", "equalto", "failed")|list|length %} +
+

Задачи

+ {% if running %}{{ running }} выполняется{% endif %} + {% if done %}{{ done }} завершено{% endif %} + {% if failed %}{{ failed }} с ошибкой{% endif %} +
+{% if flash %}

{{ flash }}

{% endif %} +
+ + + + + {% for j in jobs %} + + + + + + + + + {% else %} + + {% endfor %} + +
#УстройствоОперацияСтатусСообщениеСоздана (UTC)
{{ j.id }}{{ j.device_name }}{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW"}.get(j.type, j.type) }}{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}{{ j.message }}{{ j.created_at|dt }}
Задач пока нет.
+
diff --git a/app/ui/templates/_ui.html b/app/ui/templates/_ui.html new file mode 100644 index 0000000..e66e2ef --- /dev/null +++ b/app/ui/templates/_ui.html @@ -0,0 +1,4 @@ +{# Общие макросы: иконки (inline SVG, stroke) #} +{% macro icon(name, size=16, sw=1.8) -%} + +{%- endmacro %} diff --git a/app/ui/templates/backups.html b/app/ui/templates/backups.html new file mode 100644 index 0000000..a724c54 --- /dev/null +++ b/app/ui/templates/backups.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% import "_ui.html" as ui %} +{% block content %} +
+
+

Резервные копии

+
Бакет {{ bucket }} · {{ stats.total }} {{ stats.total|plural("файл", "файла", "файлов") }} · {{ stats.size|size }}
+
+ +
+ +
+
+
+ + + + + + + + {% if flt_active %}Сбросить ({{ flt_active }}){% endif %} +
+
+ {% if error %}{% endif %} +
+ + + + + {% for i in items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
УстройствоГруппаТипФайлРазмерИзменён (UTC)Действия
{{ i.device or "—" }}{{ i.group or "—" }}{{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }}{{ i.key.split("/")[-1] }}{{ i.size|size }}{{ i.last_modified|dt }} + {{ ui.icon("download") }} + +
Файлов не найдено.
+
+
Найдено {{ items|length }} из {{ stats.total }} {{ stats.total|plural("файла", "файлов", "файлов") }}
+
+{% endblock %} diff --git a/app/ui/templates/base.html b/app/ui/templates/base.html new file mode 100644 index 0000000..5bf1446 --- /dev/null +++ b/app/ui/templates/base.html @@ -0,0 +1,28 @@ +{% import "_ui.html" as ui %} + + + + + + {% block title %}ros_control{% endblock %} + + + + + + {% if request.session.get("user") %} + {% set nav = [("Устройства", "/", "devices"), ("Группы", "/groups", "groups"), ("Резервные копии", "/backups", "backups")] %} +
+ ros_control + + + {{ request.session.get("user") }} +
+
+ {% endif %} +
{% block content %}{% endblock %}
+ + + diff --git a/app/ui/templates/dashboard.html b/app/ui/templates/dashboard.html new file mode 100644 index 0000000..b505b7a --- /dev/null +++ b/app/ui/templates/dashboard.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% import "_ui.html" as ui %} +{% block content %} +
+
+

Устройства

+
{% include "_dev_sub.html" %}
+
+
+ + +
+
+ + + + + +
+
+
+ + + + + + + Сбросить ({{ flt_active }}) + … +
+
+ + + +
{% include "_devices.html" %}
+
+ +
{% include "_jobs.html" %}
+{% endblock %} diff --git a/app/ui/templates/device_form.html b/app/ui/templates/device_form.html new file mode 100644 index 0000000..42e0f37 --- /dev/null +++ b/app/ui/templates/device_form.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block content %} +
{% include "_device_form.html" %}
+{% endblock %} diff --git a/app/ui/templates/groups.html b/app/ui/templates/groups.html new file mode 100644 index 0000000..a45d91a --- /dev/null +++ b/app/ui/templates/groups.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% import "_ui.html" as ui %} +{% block content %} +
+
+

Группы

+
{{ groups|length }} {{ groups|length|plural("группа", "группы", "групп") }} · {{ total }} {{ total|plural("устройство", "устройства", "устройств") }}
+
+
+ +
+
+{% if error %}{% endif %} +
+ + + + + {% for g in groups %} + + + + + + {% else %} + + {% endfor %} + + + + + + +
НазваниеУстройстваДействия
{{ g.name }}{{ g.device_count }} {{ g.device_count|plural("устройство", "устройства", "устройств") }} + +
Групп пока нет. Создайте первую.
Без группы{{ ungrouped }} {{ ungrouped|plural("устройство", "устройства", "устройств") }}системная
+
+{% endblock %} diff --git a/app/ui/templates/login.html b/app/ui/templates/login.html new file mode 100644 index 0000000..d627267 --- /dev/null +++ b/app/ui/templates/login.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..43b49c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + ros_control: + build: . + env_file: .env + environment: + DATABASE_URL: sqlite:////srv/data/ros_control.db + ports: + - "8000:8000" + volumes: + - ros_data:/srv/data # именованный том: права наследуются от образа (uid 10001) + restart: unless-stopped + +volumes: + ros_data: diff --git a/docs/changes/001-initial-implementation/plan.md b/docs/changes/001-initial-implementation/plan.md new file mode 100644 index 0000000..e1e2c29 --- /dev/null +++ b/docs/changes/001-initial-implementation/plan.md @@ -0,0 +1,76 @@ +# План: ros_control — централизованное управление парком MikroTik RouterOS + +## Context + +Нужно приложение для группового администрирования множества устройств MikroTik RouterOS через API и WEB UI. Источник требований — `ros_control.svg` (draw.io): архитектура, список функций, ссылки на API. + +Из схемы: +- **Control Server** связан двусторонне с **Local Metadata DB** (DB transactions), **S3 Bucket** (Yandex Object Storage: `ListObjectsV2`, `GetObject`, `PutObject`, `DeleteObject`) и **Admin Dashboard**. +- Dashboard ↔ Control Server: JSON payload, UI Forms (ввод), JSON payload render (вывод). +- Control Server ↔ **Endpoint API Server (on device)** = RouterOS REST API (`GET/PATCH/PUT/POST/DELETE`, JSON), N устройств. + +Функции dashboard: список устройств и управление списком (добавить, изменить, удалить); статус (модель, канал обновлений, версия ROS, версия FW, uptime, время запроса бэкапа); бэкап по каждому устройству (бинарный `.backup` без шифрования + текстовый `.rsc`, загрузка в S3, удаление с устройства, скачивание из бакета, список бэкапов в бакете); выбор канала обновлений; обновление ROS до актуальной версии канала (перезагрузка после скачивания); обновление FW (перезагрузка сразу после обновления). + +Принятые решения: Python FastAPI + Jinja2/HTMX, SQLite (SQLAlchemy), пароли устройств шифруются (Fernet), вход в UI по логину/паролю, API — Bearer-токен, Docker Compose для развёртывания + `venv` в корне проекта для разработки. + +## Артефакты процесса (по CLAUDE.md проекта) + +- `docs/changes/001-initial-implementation/plan.md` — копия этого плана (создаётся первым шагом реализации) +- `docs/changes/001-initial-implementation/summary.md` — итоги по выполненному (создаётся последним шагом) +- `README.md` — описание, запуск, конфигурация, ссылки на API; обновляется при каждом изменении +- Тесты — минимальный набор (см. Verification) + +## Структура проекта + +``` +app/ + main.py # FastAPI, роутинг, lifespan + config.py # настройки из env (pydantic-settings) + db.py, models.py # SQLAlchemy + SQLite: users, devices, backups, jobs + security.py # Fernet для паролей устройств, хэш пароля админа, API-токен + ros/client.py # httpx-клиент RouterOS REST (basic auth, verify_tls per device) + ros/operations.py # status, backup, update channel/install, firmware upgrade + s3.py # boto3: list/get(presigned)/put(presigned)/delete, endpoint Yandex + services/ # devices, backups, updates, jobs (фоновые задачи + batch) + api/v1.py # JSON API + ui/routes.py, templates/, static/ # Jinja2 + HTMX +tests/ # минимальный набор +Dockerfile, docker-compose.yml, requirements.txt, .env.example, README.md +docs/changes/001-initial-implementation/{plan,summary}.md +``` + +## Ключевые технические решения + +1. **RouterOS REST** (`https:///rest/...`, ROS ≥ 7.1, сервис `www-ssl`). Для самоподписанных сертификатов — флаг `verify_tls` на устройство. Ограничение: только ROS 7. +2. **Статус** — параллельный опрос (`asyncio.gather` + семафор): `/rest/system/resource` (модель, версия ROS, uptime), `/rest/system/routerboard` (current/upgrade firmware), `/rest/system/package/update` (channel, installed/latest version). Недоступное устройство отображается как offline, не ломает список. +3. **Бэкап**: + - `POST /rest/system/backup/save` (`dont-encrypt=yes`) и `POST /rest/export` (`.rsc`); + - REST не отдаёт содержимое крупных файлов, поэтому **устройство само загружает файлы в S3** через `/tool/fetch` (`http-method=put upload=yes src-path=…`) по **presigned PUT URL**, выданному Control Server. Ключи S3: `backups//.backup|.rsc`; + - после успешной загрузки — `DELETE /rest/file/` на устройстве; время запроса пишется в БД; + - список — `ListObjectsV2` по префиксу, скачивание — presigned GET (редирект). +4. **Обновление ROS**: `POST /rest/system/package/update/set {channel}` → `check-for-updates` → `install` (устройство скачивает пакет и перезагружается само — соответствует требованию «перезагрузка после скачивания»). +5. **Обновление FW**: `POST /rest/system/routerboard/upgrade` → опрос до `current-firmware == upgrade-firmware` → `POST /rest/system/reboot`. +6. **Долгие операции** — таблица `jobs` + фоновые задачи; UI опрашивает статус через HTMX. Групповые операции: эндпоинты принимают список `device_ids`, выполняются параллельно с ограничением конкурентности. +7. **API v1**: `/api/v1/devices` (CRUD), `/devices/{id}/status`, `/devices/{id}/backups` (POST/GET), `/backups/download`, `/devices/{id}/update/channel` (PUT), `/devices/{id}/update/install`, `/devices/{id}/firmware/upgrade` (POST), `/jobs/{id}`. UI использует те же сервисы. +8. **Безопасность**: пароли устройств не возвращаются в API/UI; ключ шифрования и S3-ключи — только из env; UI — сессия после логина; API — Bearer-токен. + +## Этапы реализации + +1. Артефакты: `plan.md`, каркас проекта, `venv`, `requirements.txt`, `.env.example`. +2. Модели БД, security (Fernet, логин, токен), конфиг. +3. RouterOS-клиент и операции статуса; CRUD устройств; список со статусами (API + UI). +4. S3-модуль; бэкап (2 формата, загрузка с устройства, очистка), список и скачивание. +5. Каналы обновлений, обновление ROS, обновление FW; jobs и групповые операции. +6. Docker/compose, README, минимальные тесты, `summary.md`. + +## Риски / проверить на реальном устройстве + +- `/tool/fetch` с `http-method=put upload=yes` на целевой версии ROS (основной вариант). Запасной: устройство отправляет файл на эндпоинт Control Server. +- Синхронные REST-вызовы (`fetch`, `install`) могут упереться в таймаут — использовать `duration`/асинхронные задачи и опрос состояния. +- Сертификат `www-ssl` на устройствах и доступность 443 от Control Server; доступность S3 (`storage.yandexcloud.net`) с устройств. + +## Verification + +- **Автотесты (минимум, ~4)**: шифрование/расшифровка пароля; разбор статуса RouterOS (mock httpx); сценарий бэкапа с мокнутыми RouterOS и S3 (порядок: save → export → fetch → delete file → запись в БД); авторизация API (401 без токена). +- **Ручная проверка**: RouterOS CHR (ROS 7) в лаборатории + тестовый бакет Yandex Object Storage: добавить устройство → статус в списке → бэкап (оба файла в бакете, на устройстве удалены) → скачивание → смена канала → обновление ROS → обновление FW. +- `docker compose up` — приложение поднимается, UI на `:8000`, `/docs` показывает OpenAPI. diff --git a/docs/changes/001-initial-implementation/summary.md b/docs/changes/001-initial-implementation/summary.md new file mode 100644 index 0000000..322f7e2 --- /dev/null +++ b/docs/changes/001-initial-implementation/summary.md @@ -0,0 +1,32 @@ +# Итоги: 001 — первая версия ros_control + +## Сделано + +- Control Server на FastAPI: JSON API `/api/v1` (Bearer-токен) и WEB UI (Jinja2 + HTMX, вход по логину/паролю). +- Local Metadata DB (SQLite): устройства с зашифрованными (Fernet) паролями, кэш статуса, бэкапы, задачи. +- RouterOS REST-клиент: статус (модель, канал, ROS, FW, uptime), бэкап, смена канала, обновление ROS и FW. +- Бэкап: `.backup` (без шифрования) + `.rsc` → устройство само загружает в S3 по presigned PUT → файлы удаляются с устройства. +- S3 (Yandex Object Storage): список, скачивание (presigned GET), удаление. +- Долгие и групповые операции через таблицу `jobs` с ограничением параллелизма (`MAX_CONCURRENCY`). +- Docker Compose, `venv` для разработки, README, 4 автотеста. + +## Проверено + +- `pytest`: 4 теста (шифрование, разбор статуса, порядок шагов бэкапа с моками RouterOS/S3, авторизация API и отсутствие пароля в ответах) — пройдены. +- Дымовой прогон сервера: редирект на логин, вход, добавление недоступного устройства (offline + причина), задача бэкапа завершается `failed` с понятной ошибкой, страница бэкапов открывается. + +## НЕ проверено (нужно устройство и бакет) + +- Реальная работа с RouterOS 7 и Yandex Object Storage. Главный риск — `/tool/fetch` с `http-method=put upload=yes` на целевой версии ROS. +- Обновления ROS/FW (поведение при обрыве соединения на перезагрузке реализовано по документации). +- Сборка и запуск Docker-образа не выполнялись. + +## Отклонения от плана + +- Администратор UI берётся из переменных окружения (`ADMIN_USER`/`ADMIN_PASSWORD`), таблицы `users` нет — достаточно для одного администратора. +- Кнопки в строке устройства и групповые кнопки используют `hx-include` вместо общей формы (иначе значения каналов разных устройств смешивались). + +## Ограничения + +- Только RouterOS 7.x. Один администратор. HTMX-скрипт лежит локально (`app/ui/static`), внешние CDN не нужны. +- Долгие задачи выполняются внутри процесса сервера; при перезапуске незавершённые помечаются `failed`. diff --git a/docs/changes/002-http-support-and-fixes/plan.md b/docs/changes/002-http-support-and-fixes/plan.md new file mode 100644 index 0000000..5c92019 --- /dev/null +++ b/docs/changes/002-http-support-and-fixes/plan.md @@ -0,0 +1,15 @@ +# План: 002 — HTTP-подключение к устройствам и исправления по итогам первого теста + +## Контекст +Тест на реальном RouterOS (RB4011, ROS 7.24.4): статус не считывался, бэкап не создавался (`ConnectError`). +Причины: у устройства включён только `www` (порт 80, HTTP), а `www-ssl` (443) выключен; в приложении был указан порт 8729 (`api-ssl` — бинарный протокол, не REST). + +## Решение (выбрано пользователем) +Поддержка HTTP на уровне устройства: флаг `use_tls` (по умолчанию `true`; `false` = HTTP, только для доверенной сети). + +## Шаги +1. Поле `use_tls` в модели `Device` + миграция существующей БД (`ALTER TABLE`). +2. `RosClient`: схема `http/https` по флагу; в ошибку подключения добавлен URL и причина. +3. API (`DeviceIn/Patch/Out`) и UI-форма: флаг «HTTPS» + предупреждение о пароле открытым текстом. +4. Исправление: RouterOS может отдавать имена файлов не в UTF-8 — декодирование с заменой вместо падения. +5. Перевод тестового устройства на порт 80/HTTP, проверка статуса и бэкапа, диагностика прав пользователя. diff --git a/docs/changes/002-http-support-and-fixes/summary.md b/docs/changes/002-http-support-and-fixes/summary.md new file mode 100644 index 0000000..13f33fd --- /dev/null +++ b/docs/changes/002-http-support-and-fixes/summary.md @@ -0,0 +1,19 @@ +# Итоги: 002 — HTTP-подключение и исправления + +## Сделано +- Флаг `use_tls` у устройства (API, UI, БД + автомиграция колонки). HTTP — только для доверенной сети. +- Сообщения об ошибках подключения содержат URL и тип ошибки (раньше — пустое `ConnectError:`). +- Исправлено падение на не-UTF-8 ответах RouterOS (`GET /file`). +- Устройство `msk-home` переведено на порт 80/HTTP: статус считывается (модель, канал, ROS, FW, uptime). + +## Найдено при тесте +- Порт 8729 — `api-ssl`, не REST. REST: `www-ssl` (443) или `www` (80). +- Пользователь `apicontrol` в группе `write` с `!ftp,!policy`: `backup/save`, `export` → `not enough permissions (9)`; + `/tool/fetch` → `cannot open file: permission denied`. Нужна политика `ftp` (при необходимости `policy`). +- `ros_latest`/`ros_update_status` пусты, пока не выполнена проверка обновлений (`check-for-updates`). + +## Не проверено +Бэкап целиком (ждёт выдачи прав пользователю), загрузка в S3 через `/tool/fetch`, обновления ROS/FW. + +## Дополнение +Права `apicontrol` были причиной ошибок `not enough permissions`: после выдачи `full` и пересоздания подключения `backup/save` заработал (см. 003). Загрузка через `/tool/fetch` оказалась неприменима — заменена в 003. diff --git a/docs/changes/003-download-via-rest-upload-from-server/plan.md b/docs/changes/003-download-via-rest-upload-from-server/plan.md new file mode 100644 index 0000000..5987526 --- /dev/null +++ b/docs/changes/003-download-via-rest-upload-from-server/plan.md @@ -0,0 +1,16 @@ +# План: 003 — скачивание бэкапов через REST, загрузка в S3 сервером + +## Контекст +Схема 001 (устройство само грузит в S3 по presigned PUT через `/tool/fetch`) не работает: на ROS 7.24 `fetch` поддерживает upload только по FTP/SFTP. +Решение пользователя: Control Server сам скачивает файлы с устройства **через REST-канал** и сам загружает их в S3. Устройство S3 не касается. + +## Проверено на устройстве (RB4011, ROS 7.24.4) +- `GET /rest/file/` для файлов >4 КБ не отдаёт `contents` (231 КБ .backup, 75 КБ .rsc). +- `POST /rest/file/read {file, offset, chunk-size}` возвращает блок `data` — подходит для .rsc. +- `POST /rest/execute {script, as-string}` с `:convert to=base64` возвращает блок бинарного файла без искажений в JSON. + +## Шаги +1. `ros/operations.py`: `download_file()` — чтение блоками (32 КБ) через `execute` + base64 в файл, проверка итогового размера; убрать `upload_file()` (fetch). +2. `s3.py`: `upload_file()` (PutObject, boto3 в потоке); убрать presigned PUT. +3. `services/ops.py::run_backup`: создать файлы → скачать во временный каталог → загрузить в S3 → удалить файлы с устройства (в т.ч. при сбое); временный каталог всегда удаляется. +4. Тест бэкапа, README, summary; проверка на `msk-home`. diff --git a/docs/changes/003-download-via-rest-upload-from-server/summary.md b/docs/changes/003-download-via-rest-upload-from-server/summary.md new file mode 100644 index 0000000..a537928 --- /dev/null +++ b/docs/changes/003-download-via-rest-upload-from-server/summary.md @@ -0,0 +1,25 @@ +# Итоги: 003 — скачивание через REST, загрузка в S3 сервером + +## Сделано +- Бэкап: Control Server создаёт `.backup` и `.rsc` через REST → скачивает их блоками по 32 КБ во временный каталог + (`POST /rest/execute` + `:convert to=base64`) → загружает в S3 (`PutObject`) → удаляет файлы с устройства + (в том числе при сбое). Временный каталог удаляется всегда. Устройство S3 не касается. +- Удалены `/tool/fetch`-загрузка и presigned PUT. Проверка целостности: сравнение размера на устройстве и скачанного. +- Экспорт: пример из документации (`compact`) на ROS 7 даёт побайтно тот же результат, поэтому запрос остался без `compact`. + +## Проверено на RB4011 (ROS 7.24.4) и бакете Yandex +- Бэкап `MSK_Home`: `.backup` 231 237 Б и `.rsc` 74 924 Б в бакете, размеры совпадают с устройством, + сигнатура `.backup` = `88 AC A1 B1`, `.rsc` начинается с заголовка экспорта; на устройстве `ros_control-*` не осталось. +- Скачивание из бакета через API (`/backups/download`) работает. `pytest`: 4 из 4. + +## Причина прежних ошибок доступа (002) +Права применились только после пересоздания подключения к устройству (пользователь `full`); +сама смена политик группы без этого не помогала. + +## Ограничения +- `.rsc` создаётся без `show-sensitive`: пароли и ключи в нём скрыты (для полного восстановления служит `.backup`). +- Скорость скачивания ограничена размером блока и задержкой REST (около 8 запросов на 230 КБ); для очень больших файлов + можно увеличить `chunk_size`. + +## Не проверено +Обновление ROS и FW (перезагрузка устройства), групповые операции над несколькими устройствами. diff --git a/docs/changes/004-upgrade-columns-and-actions-menu/plan.md b/docs/changes/004-upgrade-columns-and-actions-menu/plan.md new file mode 100644 index 0000000..5bd147f --- /dev/null +++ b/docs/changes/004-upgrade-columns-and-actions-menu/plan.md @@ -0,0 +1,46 @@ +# План: 004 — колонки Upgrade ROS / Upgrade FW и меню действий «⋯» в UI + +## Context + +Дашборд показывает только текущие версии; доступные для обновления версии видны лишь мелким текстом под ROS/FW и для ROS почти никогда не заполнены. Причины: +- `latest-version` в `/rest/system/package/update` появляется только после `check-for-updates` — приложение его при опросе статуса не вызывает (`app/ros/operations.py::get_status`). +- `ros_version` из `system/resource` имеет вид `7.24.4 (stable)`, а `latest-version` — `7.24.4`; прямое сравнение в шаблоне всегда даёт «отличаются». +- В строке устройства пять кнопок в ряд (Статус, Бэкап, ROS, FW, Изменить) — таблица перегружена. + +Цель: отдельные колонки **Upgrade ROS** и **Upgrade FW** с версией, до которой можно обновиться, и все действия строки — в выпадающем меню под иконкой «⋯». + +## Изменения + +**1. Статус: проверка обновлений** — `app/ros/operations.py::get_status` +- Перед чтением `system/package/update` выполнять `POST system/package/update/check-for-updates` (таймаут ~30 с). Ошибка проверки (нет интернета на устройстве, таймаут) не ломает опрос: остаётся то, что вернул `GET`, в статус пишется `ros_check_error`. +- В статус добавить `ros_installed` (`installed-version`, без суффикса «(stable)») — именно с ним сравнивается `ros_latest`. +- `get_status(c, check_updates=True)`: параметр нужен только чтобы тест мог отключить проверку. +- `set_channel` уже вызывает `refresh_status`, значит после смены канала доступная версия пересчитается сама. Инструмент `install_ros_update` (`check-for-updates` + `install`) не меняется. + +**2. Шаблон `app/ui/templates/_devices.html`** +- Колонки: `…, ROS, Upgrade ROS, FW, Upgrade FW, …`. Убрать мелкие подсказки «доступна X» из колонок ROS/FW. +- Upgrade ROS: `ros_latest`, если он есть и `!= ros_installed` → выделенный бейдж с версией; равен → «✓ актуально»; нет данных → «—» (в `title` — `ros_check_error`). +- Upgrade FW: `fw_upgrade`, если `!= fw_current` → бейдж; равен → «✓ актуально»; нет RouterBOARD (CHR) → «—». +- Колонка «Действия» → компактная колонка с кнопкой `⋯`; меню на `` (без внешних библиотек). Пункты те же, что были кнопками: Обновить статус, Бэкап, Обновить ROS, Обновить FW, Изменить. Атрибуты `hx-post` / `hx-target` / `hx-confirm` переносятся как есть. +- `colspan` пустой строки 9 → 10. + +**3. Стили и поведение** — `app/ui/static/style.css`, `app/ui/templates/base.html` +- CSS: `.menu` (relative), `.menu-list` (absolute, справа, тень, границы под тёмную и светлую темы, `z-index`), пункты-кнопки на всю ширину, опасные (ROS/FW) — акцентным цветом; `.badge` для Upgrade-версий (цвет `--warn`), «актуально» — `--ok`. +- ~6 строк JS в `base.html`: закрывать открытое меню по клику вне его, по `Esc` и после выбора пункта (нужно, потому что действия с `hx-target="#jobs"` таблицу не перерисовывают и меню иначе останется открытым). + +**4. Артефакты проекта (CLAUDE.md)** +- `docs/changes/004-upgrade-columns-and-actions-menu/plan.md` (копия этого плана) и `summary.md` в конце. +- `README.md`: описание колонок и меню, строка в «Историю изменений». +- Тесты: без новых; в существующем `test_status_parsing` (`tests/test_app.py`) добавить обработку `POST …/check-for-updates` в моке и проверку `ros_installed`/`ros_latest`. + +## Проверка + +- `pytest` (4 теста). +- `docker compose up -d --build --force-recreate`, затем `POST /api/v1/devices/1/refresh`: в статусе появились `ros_installed`, `ros_latest`; время опроса с проверкой обновлений замерить — при заметном замедлении оставить `check_updates` только в кнопке «Обновить статус», а не при каждом опросе. +- Визуально в браузере (`http://localhost:8000`): колонки Upgrade ROS/FW, меню «⋯» открывается/закрывается (клик вне, Esc, выбор пункта), пункты работают (Статус, Бэкап, ROS/FW — с подтверждением, Изменить), светлая и тёмная тема, узкое окно. +- Обновления ROS/FW фактически **не запускаются** (перезагрузка устройства); проверяется только отображение и подтверждение. + +## Риски + +- `check-for-updates` ходит с устройства на серверы MikroTik: без интернета вернётся ошибка/таймаут → «—» с подсказкой, статус остаётся `online`. +- Проверка добавляет секунды к опросу каждого устройства; параллелизм ограничен `MAX_CONCURRENCY` (см. пункт проверки выше). diff --git a/docs/changes/004-upgrade-columns-and-actions-menu/summary.md b/docs/changes/004-upgrade-columns-and-actions-menu/summary.md new file mode 100644 index 0000000..0823cab --- /dev/null +++ b/docs/changes/004-upgrade-columns-and-actions-menu/summary.md @@ -0,0 +1,19 @@ +# Итоги: 004 — колонки Upgrade ROS / Upgrade FW и меню действий «⋯» + +## Сделано +- Статус устройства теперь включает проверку обновлений (`check-for-updates`): `ros_latest`, `ros_installed`, `ros_check_error`. + Сбой проверки (нет интернета на устройстве) не делает устройство offline — в колонке «—» с подсказкой. +- UI: колонки **Upgrade ROS** и **Upgrade FW** — бейдж `↑ версия` (есть обновление), `✓ актуально`, либо `—` (нет данных / нет RouterBOARD). +- Исправлено сравнение версий ROS: раньше `7.24.4 (stable)` (resource) сравнивалось с `7.24.4` (latest) и всегда «отличалось». + Теперь используется `installed-version`. +- UI: пять кнопок строки заменены выпадающим меню под иконкой «⋯» (Обновить статус, Создать бэкап, Обновить ROS, Обновить FW, Изменить). + Меню закрывается по клику вне, по Esc и после выбора пункта; без внешних библиотек. + +## Проверено +- `pytest`: 4 из 4 (тест статуса дополнен проверкой `check-for-updates` и `ros_installed`). +- На `msk-home` (ROS 7.24.4): опрос с проверкой обновлений 1,2–1,5 с; `ros_latest = ros_installed = 7.24.4`, `fw_current = fw_upgrade = 7.24.4` → в обеих колонках «✓ актуально». +- Рендер шаблона на тестовых данных: ветки «есть обновление ROS и FW», «нет интернета на устройстве», «CHR без RouterBOARD» отображаются верно. + +## Не проверено +- Внешний вид и поведение меню в браузере (на машине разработки браузера нет): позиционирование, светлая/тёмная тема, узкое окно. +- Фактический запуск обновлений ROS/FW не выполнялся (перезагрузка устройства) — проверялось только отображение. diff --git a/docs/changes/005-groups-and-filters/plan.md b/docs/changes/005-groups-and-filters/plan.md new file mode 100644 index 0000000..ba0d85c --- /dev/null +++ b/docs/changes/005-groups-and-filters/plan.md @@ -0,0 +1,56 @@ +# План: 005 — группы устройств и фильтры (список устройств, список бэкапов) + +## Context + +Парк устройств растёт, а дашборд показывает всё одним списком, и в бакете бэкапы тоже идут одним списком. Нужно: +1. **Группы** — новая сущность: устройство относится к одной группе (или ни к одной); таблица показывает устройства выбранной группы. +2. **Фильтры** на странице устройств и на странице бэкапов. + +Принятые решения (по формулировке «относятся к одной группе»): у устройства **одна** группа (`group_id`, может быть пустой = «Без группы»); переключение групп — вкладки над таблицей; по умолчанию — «Все». Фильтры работают на серверной стороне. + +## Изменения + +**1. Модель и БД** — `app/models.py`, `app/db.py::_migrate` +- Таблица `groups` (`id`, `name` уникальное, ≤64 символов, кириллица и пробелы допустимы — в ключи S3 имя группы не попадает). +- `devices.group_id` (nullable). Миграция существующей БД: `ALTER TABLE devices ADD COLUMN group_id INTEGER` рядом с миграцией `use_tls`. +- Внешние ключи в SQLite не включены, поэтому при удалении группы устройства открепляются явно (`UPDATE devices SET group_id = NULL`), устройства не удаляются. + +**2. Сервисы** +- `app/services/groups.py` (новый): `list_groups` (с числом устройств), `create/rename/delete_group`. +- `app/services/devices.py`: `group_id` в `create_device`/`update_device` (для открепления допустимо явное `None`); `filter_devices(devices, group, q, status, updates, channel)` — общий для UI и API. `group`: пусто = все, `none` = без группы, иначе id. `q` — по имени, адресу, модели. `status`: online/offline. `updates`: `ros` / `fw` / `any` / `none` (по `ros_latest != ros_installed`, `fw_upgrade != fw_current` из кэша статуса). `channel` — канал обновлений. +- `app/services/backups.py` (новый): `list_backups(device, group, kind, date_from, date_to, q)` поверх `s3.list_backups` (`app/s3.py:48`); имя устройства берётся из ключа `backups/<устройство>/<файл>`, группа — по имени устройства; добавляет поля `device`, `group`, `kind` (`backup`/`rsc`). Бэкапы удалённых устройств остаются в «Все», группа у них «—». Даты — по `LastModified`, UTC, включительно. + +**2. API** — `app/api/v1.py` +- `GET/POST /api/v1/groups`, `PATCH/DELETE /api/v1/groups/{id}`. +- `group_id` в `DeviceIn`/`DevicePatch`/`DeviceOut`; `GET /devices?group=&q=&status=&updates=&channel=`. +- `GET /backups?device_id=&group=&kind=&date_from=&date_to=&q=` через `services/backups.py`. +- Групповые операции: `BatchIn` принимает `device_ids` **или** `group_id` (операция над всей группой). + +**3. UI — устройства** — `app/ui/routes.py`, `dashboard.html`, `_devices.html` +- Вкладки групп над таблицей: «Все (N)», «Без группы (N)», по каждой группе (N) — обычные ссылки `/?group=…` с сохранением остальных фильтров (полная перезагрузка, URL можно сохранить в закладки; счётчики всегда актуальны). +- Форма фильтров `#dev-filters` (поля с префиксом `f_`, чтобы не пересекаться с `channel` у селектов канала): поиск `f_q`, статус, обновления, канал + скрытое `f_group`. `hx-get="/ui/devices"`, `hx-trigger="input delay:300ms, change"`, обновляет `#devices`; ответ содержит заголовок `HX-Push-Url` с адресом страницы, поэтому фильтр переживает перезагрузку. Кнопка «Сбросить». +- Фильтр сохраняется после действий, возвращающих таблицу (обновить статус, смена канала, групповые): у этих кнопок `hx-include="#dev-filters"`, маршруты разбирают `f_*` из формы. Чекбокс «выбрать все» затрагивает только видимые строки. +- Перемещение в группу: отдельная форма `#move-form` (обычный POST + редирект, поэтому счётчики вкладок обновляются) — выпадающий список групп + кнопка для отмеченных устройств (чекбоксы связаны атрибутом `form="move-form"`). Плюс поле «Группа» в форме создания/изменения устройства (`device_form.html`). +- Страница **«Группы»** (`/groups`, пункт в навигации `base.html`): список с числом устройств, создание, переименование, удаление (с подтверждением; устройства остаются, становятся «Без группы»). + +**4. UI — бэкапы** — `backups.html`, маршрут `/backups` +- Обычная GET-форма (закладки, без HTMX; список всё равно запрашивается из S3): группа, устройство, тип (`.backup`/`.rsc`), дата с/по, поиск по имени файла. Селекты и даты отправляются по `change`, поиск — по Enter; кнопка «Сбросить». +- Новые колонки: Устройство, Группа, Тип. + +**5. Артефакты проекта (CLAUDE.md)** +- `docs/changes/005-groups-and-filters/plan.md` (копия) и `summary.md`; README: группы, фильтры, новые API-маршруты, строка в «Историю изменений». +- Тесты (минимум, +2): (а) группы + `filter_devices`: фильтр по группе/«без группы»/статусу/обновлениям, удаление группы открепляет устройства; (б) `services/backups.list_backups` с подменённым `s3.list_backups`: фильтры по группе, типу и датам. + +## Проверка + +- `pytest` (4 существующих + 2 новых). +- `docker compose up -d --build --force-recreate` (миграция БД должна сохранить `msk-home`). +- API (curl): создать группу, привязать `msk-home`, `GET /devices?group=…`, `?group=none`, `?status=offline`; `GET /backups?group=…&kind=rsc`; групповой бэкап по `group_id` **не запускать без вашего подтверждения** (создаёт файлы на устройстве) — проверить только валидацию. +- UI (curl с cookie): `/?f_group=…&f_q=…` даёт нужные строки и вкладки со счётчиками; запрос с заголовком `HX-Request` к `/ui/devices` возвращает таблицу и заголовок `HX-Push-Url`; `/backups?…` фильтрует реальные файлы в бакете. +- **Ограничение:** на машине нет браузера, живое поведение HTMX (обновление по вводу, вид вкладок) увижу только по серверным ответам; внешний вид и живой фильтр нужно посмотреть вам. + +## Риски + +- Без FK-каскадов целостность групп обеспечивается кодом (`delete_group`), не БД. +- Фильтры «обновления» опираются на кэш статуса: у не опрошенных устройств поля пусты — они не попадают в «есть обновление». +- Имя устройства берётся из ключа S3: устройство, переименованное после бэкапа, потеряет связь с группой в списке бэкапов (старые файлы останутся под прежним именем). diff --git a/docs/changes/005-groups-and-filters/summary.md b/docs/changes/005-groups-and-filters/summary.md new file mode 100644 index 0000000..5e3d69e --- /dev/null +++ b/docs/changes/005-groups-and-filters/summary.md @@ -0,0 +1,29 @@ +# Итоги: 005 — группы устройств и фильтры + +## Сделано +- **Группы**: таблица `device_groups`, у устройства одна группа (`group_id`, пусто = «Без группы»). Миграция существующей БД автоматическая. + Удаление группы не удаляет устройства — они открепляются (FK в SQLite не включены, откреплением занимается код). +- **UI, устройства**: вкладки групп со счётчиками («Все», «Без группы», каждая группа) — таблица показывает устройства выбранной группы; + колонка «Группа»; фильтры: поиск (имя/адрес/модель), статус, обновления (ROS/FW/любое/актуально), канал. + Фильтр применяется на лету (HTMX), URL страницы обновляется (`HX-Push-Url`), фильтр сохраняется после действий над таблицей. + «Обновить статус» без выбора теперь обновляет устройства текущего представления, а не весь парк. +- **UI, группы**: страница «Группы» (создать, переименовать, удалить); перенос отмеченных устройств в группу; поле «Группа» в форме устройства. +- **UI, бэкапы**: фильтры — группа, устройство, тип (`.backup`/`.rsc`), период дат, поиск по имени файла; колонки «Устройство», «Группа», «Тип». +- **API**: `/api/v1/groups` (CRUD); `group_id` у устройства; фильтры `GET /devices?group&q&status&updates&channel` + и `GET /backups?device_id&group&kind&date_from&date_to&q`; групповые операции по `group_id` в `/batch/*`. + +## Проверено +- `pytest`: 6 из 6 (добавлено 2: группы + фильтры устройств, фильтры бэкапов). +- Миграция на существующей БД: `MSK_Home` сохранился. API: создание/переименование/удаление групп, дубликат (400), несуществующая группа (404), + все фильтры устройств и бэкапов на реальном бакете, валидация батча по группе (запуск реальных операций не выполнялся). +- UI (curl): вкладки и счётчики, фильтры, заголовок `HX-Push-Url`, перенос устройства между группами, защита от open redirect в `next`, + страницы «Группы», «Бэкапы» с фильтрами. +- Тестовые группы («Москва», «Резерв») после проверки удалены, устройство осталось без группы. + +## Не проверено +- Живое поведение в браузере (на машине разработки браузера нет): обновление таблицы по вводу, вид вкладок и форм фильтров. + +## Ограничения +- Группа определяется по имени устройства в ключе S3 (`backups/<устройство>/…`): бэкапы удалённого или переименованного устройства + отображаются с группой «—» и попадают в «Без группы». +- Фильтр по обновлениям использует кэш статуса; у ещё не опрошенных устройств поля пусты. diff --git a/docs/changes/006-ui-redesign/plan.md b/docs/changes/006-ui-redesign/plan.md new file mode 100644 index 0000000..2df9f5f --- /dev/null +++ b/docs/changes/006-ui-redesign/plan.md @@ -0,0 +1,33 @@ +# План: 006 — редизайн UI (макет на ревью) + +Статус: **макет принят заказчиком, внедрено** (см. `summary.md`). Ниже — исходное предложение. Макет: https://claude.ai/artifact/QdhgQWbQEWRYtBoAWMjGq6 (7 листов: разбор текущего UI, компоненты и уровни, «Устройства» в трёх состояниях, «Бэкапы», «Группы»). + +## Проблемы текущего UI +1. Над таблицей три-четыре панели подряд (вкладки групп, фильтры, действия над выбранными, перенос в группу) выглядят одинаково — уровни не отделены. +2. Разные формы контролов: вкладки-пилюли (r16), кнопки и поля (r5), бейджи (r10), пунктирная «вкладка». +3. Действия над выбранными видны всегда; «Применить канал» и «Переместить» — двухшаговые. +4. 12 колонок, выпадающий список канала в каждой строке. + +## Предложение +- **Одна форма контролов**: высота 36, радиус 8; три вида кнопок (primary — одна на экран, secondary, ghost); список-фильтр выглядит как secondary с шевроном; активный фильтр — контур и тон акцента. Метки: высота 22, радиус 6. Вкладки — подчёркивание, не кнопки. +- **Уровни экрана**: 1 приложение (навигация) → 2 страница (заголовок, счётчики, главное действие) → 3 область (вкладки групп) → 4 инструменты таблицы → 5 данные → задачи отдельной карточкой. +- **Инструменты таблицы — одна полоса двух состояний**: нет выбора — фильтры; выбрано N — действия над выбранными (Создать бэкап, Обновить статус, Обновление ▾, Канал ▾, В группу ▾, Снять выбор). ROS/FW — в меню с пояснением про перезагрузку. +- **Таблица**: модель уходит второй строкой в ячейку устройства; канал только показывается (смена — через «Канал ▾» и меню строки); меню «⋯» сгруппировано разделителями. +- «Управление группами» — ссылка справа от вкладок и пункт верхнего меню (не вкладка). Бэкапы: та же полоса фильтров, действия в строке — «Скачать» (иконка) и «⋯». + +## Вопросы к ревью +1. Убрать выбор канала из строк? 2. Слить «Модель» в ячейку устройства? 3. Действия заменяют фильтры при выборе — ок? +4. «Управление группами» в двух местах — оставить оба? 5. Подтверждение обновлений: окно браузера или модальное со списком устройств? 6. Тёмная тема — сохраняем (палитра на тех же токенах). + +## Внедрение (после утверждения макета) +1. Токены и компоненты в `app/ui/static/style.css` (кнопки, поля, метки, вкладки, карточка, меню), убрать разнобой радиусов. +2. `base.html`, `dashboard.html`: заголовок страницы, вкладки-подчёркивание, единая полоса инструментов (фильтры ⇄ действия при выборе — HTMX + небольшой JS для счётчика выбора). +3. `_devices.html`: слияние колонки «Модель», канал — текстом, сгруппированное меню строки; меню «Обновление / Канал / В группу» вместо отдельных форм (маршрут `/ui/move` сохраняется). +4. `backups.html`, `groups.html`, `device_form.html` — на тех же компонентах. +5. Проверка: pytest, рендер шаблонов и `curl`; внешний вид — на ревью у заказчика (браузера на машине разработки нет). Итоги в `summary.md`, README — в «Историю изменений». + + +## Принято к внедрению (дополнение) +- Стиль макета переносится в точности; окна добавления/изменения устройства и групп проектируются в том же стиле (листы «Окно: …» в макете). +- Два бага, найденные заказчиком: (1) перезагрузка после обновления FW должна идти по записи в журнале «Firmware upgraded successfully, please reboot for changes to take effect!»; (2) при добавлении устройства нельзя привязать его к группе. +- Причины и подробный план внедрения — `docs/changes/006-ui-redesign/summary.md` (раздел «Баги») и утверждённый план в истории работ. diff --git a/docs/changes/006-ui-redesign/summary.md b/docs/changes/006-ui-redesign/summary.md new file mode 100644 index 0000000..c53654c --- /dev/null +++ b/docs/changes/006-ui-redesign/summary.md @@ -0,0 +1,36 @@ +# Итоги: 006 — перенос утверждённого макета в UI и два бага + +## Баги +1. **Прошивка не перезагружала устройство.** `upgrade_firmware` ждал `current-firmware == upgrade-firmware`, но `current-firmware` + меняется только после перезагрузки (на `OZ_Dacha`: `7.22.3` при `upgrade 7.24.4`, задача №12 упала по таймауту). + Теперь триггер — новая запись журнала «Firmware upgraded successfully, please reboot for changes to take effect!» + (журнал читается целиком и фильтруется на сервере: REST-фильтр `?message=` по точному тексту ничего не вернул). + Если запись новее последней загрузки (по `system/clock` − `uptime`) уже есть, то повторная команда не нужна — сразу перезагрузка. +2. **Нельзя привязать создаваемое устройство к группе.** Форма и маршрут работали, но групп в экземпляре не было (тестовые я удалил + после проверки), а создать группу из формы было нельзя. Теперь в окне устройства в списке «Группа» есть пункт «+ Новая группа…»: + группа создаётся и привязывается в одной транзакции (при ошибке лишней группы не остаётся). Если сценарий был другим — сообщить. + +## Интерфейс (перенос макета) +- Дизайн-система в `app/ui/static/style.css`: токены и размеры макета 1:1 (контролы h36 r8, метки h22 r6, панели r12), тёмная тема на тех же токенах. + Шрифты IBM Plex Sans/Mono лежат локально (`static/fonts`, лицензия OFL) — внешний CDN не нужен. +- Страницы: «Устройства» (заголовок со счётчиками, вкладки групп, карточка с полосой «фильтры ⇄ действия над выбранными», таблица, задачи), + «Группы», «Резервные копии» (фильтры на `.field`, «Скачать» и «⋯» в строке), вход. +- Окна (`` + HTMX): добавление/изменение устройства, создание/переименование группы; ошибки — внутри окна, успех — обновление страницы. + Запасные страницы `/devices/new`, `/devices/{id}/edit` сохранены. +- Логика выбора строк, меню, окон и фильтров — `static/app.js` (без библиотек, кроме htmx). + +## Проверено +- `pytest`: 8 из 8 (добавлены: перезагрузка по записи журнала — оба сценария; добавление устройства из окна с группой и «новой группой», окна групп). +- Реальный браузер (headless Chromium): вход → окно «Новое устройство» → «Новая группа…» → вкладка «QA группа 1» → выбор строк и панель действий, + меню «Обновление / В группу», меню строки с подменю каналов, живой фильтр без перезагрузки и «Сбросить (N)», страницы «Группы» и «Бэкапы»; + ошибок JS/консоли нет; тестовые данные удалены (в БД остались `MSK_Home`, `OZ_Dacha`, групп нет). +- По скриншотам исправлены расхождения с макетом: пробел в «Выбрано: N», двойные границы карточек, «системная» за краем таблицы, + закрытие подменю каналов, склейка «Сбросить (N)», серые «—» у offline-устройства, знак «+» в списке групп. + +## Не проверено +- Реальная перезагрузка по «Обновить FW» на `OZ_Dacha` — нужно подтверждение (перезагрузит роутер); логика проверена на моках и на реальном журнале (чтение). +- Соответствие макету на других размерах окна и в тёмной теме (проверялось при 1440 px в светлой). + +## Отличия от макета +- Календарь в фильтре дат — нативный ``: формат зависит от локали браузера (в макете ISO). +- Фильтры вкладок и списков в приложении работают; в макете они статичны. diff --git a/docs/changes/007-fast-offline-detection/plan.md b/docs/changes/007-fast-offline-detection/plan.md new file mode 100644 index 0000000..7247feb --- /dev/null +++ b/docs/changes/007-fast-offline-detection/plan.md @@ -0,0 +1,14 @@ +# План: 007 — быстрое обнаружение недоступности устройств + +## Контекст +Раньше опроса по расписанию не было: статус обновлялся только по действию пользователя, а недоступное (не отвечающее) устройство определялось по общему таймауту 30 с. + +## Решение (согласовано: оба пункта, интервал 30 с) +1. **Раздельные таймауты**: соединение — 4 с (`ROS_CONNECT_TIMEOUT`), чтение — 30 с (долгие операции не затронуты). +2. **Фоновый опрос** каждые 30 с (`POLL_INTERVAL`, 0 — выключить): параллельно, не более `MAX_CONCURRENCY`. + - Лёгкий опрос (один запрос `system/resource`): online/offline, uptime, версия ROS. + - Полный опрос: если устройство было offline или не опрошено (версии могли измениться), и раз в `UPDATE_CHECK_INTERVAL` (30 мин) — с проверкой обновлений. +3. **UI**: таблица и счётчики в заголовке обновляются сами каждые 15 с (только чтение БД); пауза при выбранных строках, открытом меню/окне, фокусе в фильтрах и скрытой вкладке; история браузера не засоряется. + +## Шаги +Клиент RouterOS (connect-таймаут) → `ops.poll_device` → `services/poller.py` и запуск в `lifespan` → автообновление в `app.js` (OOB-подзаголовок) → тест, README, итоги. diff --git a/docs/changes/007-fast-offline-detection/summary.md b/docs/changes/007-fast-offline-detection/summary.md new file mode 100644 index 0000000..ca85758 --- /dev/null +++ b/docs/changes/007-fast-offline-detection/summary.md @@ -0,0 +1,19 @@ +# Итоги: 007 — быстрое обнаружение недоступности + +## Сделано +- Таймаут соединения 4 с отдельно от таймаута чтения 30 с (`RosClient`, `ROS_CONNECT_TIMEOUT`). +- Фоновый опрос (`app/services/poller.py`, запускается в `lifespan`): период `POLL_INTERVAL=30` с, лёгкий и полный режимы + (`ops.poll_device`); сбой одного устройства не останавливает остальных, при остановке приложения задача корректно отменяется. +- Автообновление страницы «Устройства» каждые 15 с без перезагрузки и без потери выбора/открытых меню (`static/app.js`, `HX-Push-Url` при автообновлении не отправляется). +- Новые настройки в `.env.example`: `ROS_CONNECT_TIMEOUT`, `POLL_INTERVAL`, `UPDATE_CHECK_INTERVAL` (значения по умолчанию заданы в коде — существующий `.env` менять не нужно). + +## Проверено +- `pytest`: 9 из 9 (добавлен тест опроса: недоступно → возврат с полным опросом → лёгкий опрос обновляет uptime, остальные поля сохраняются; в тестах опрос выключен). +- Вживую: статусы обновляются каждые 30 с без обращений пользователя; адрес без ответа определяется за 4,0 с (было до 30 с); + новое недоступное устройство помечается offline за ≤24 с без ручного опроса; в браузере страница сама показала «1 offline» через 15 с, + история не засорена, ошибок JS нет. Тестовые устройства удалены. + +## Ограничения +- Худшее время обнаружения: `POLL_INTERVAL` + `ROS_CONNECT_TIMEOUT` (≈34 с) плюс до 15 с до обновления страницы. +- Во время перезагрузки устройства (обновление ROS/FW) оно кратковременно отображается как offline — это ожидаемо. +- Интервал 30 с при большом парке даёт по одному лёгкому запросу на устройство в 30 с; нагрузку ограничивает `MAX_CONCURRENCY`. diff --git a/docs/changes/008-device-note/plan.md b/docs/changes/008-device-note/plan.md new file mode 100644 index 0000000..1aa67bc --- /dev/null +++ b/docs/changes/008-device-note/plan.md @@ -0,0 +1,8 @@ +# План: 008 — примечание к устройству + +Запрос: комментарий владельца к листу «Окно: добавление устройства» в макете — «добавь место, где можно будет написать примечание». + +## Решение +- Макет: в окне устройства под полем «Название» — многострочное поле «Примечание» (необязательно). Прод повторяет макет. +- Приложение: `devices.note` (TEXT, до 500 символов, пробелы по краям обрезаются, пусто = нет), миграция БД, поле в форме окна и запасной страницы, `note` в API (`POST/PATCH/GET /api/v1/devices`), примечание показывается подсказкой (`title`) над именем устройства в таблице — внешний вид таблицы не меняется. +- Тест: примечание через окно (обрезка пробелов, пусто → null). diff --git a/docs/changes/008-device-note/summary.md b/docs/changes/008-device-note/summary.md new file mode 100644 index 0000000..216cede --- /dev/null +++ b/docs/changes/008-device-note/summary.md @@ -0,0 +1,14 @@ +# Итоги: 008 — примечание к устройству + +## Сделано +- Макет (лист «Окно: добавление устройства», версия 4) и приложение: поле «Примечание» под названием устройства (textarea, до 500 символов). +- БД: колонка `devices.note` (автомиграция), сервис (`_clean_note`: обрезка, лимит, пусто → `null`), API (`note` в создании, правке и ответах; ошибка 400 при превышении лимита), UI (окно и запасная страница). +- В таблице примечание видно во всплывающей подсказке над именем устройства. + +## Проверено +- `pytest`: 9 из 9 (тест создания устройства из окна расширен проверкой примечания). +- В браузере окно с полем «Примечание» выглядит как в макете; примечание сохраняется, видно в окне изменения и подсказке; через API правится, очищается пустой строкой, длиннее 500 символов отклоняется (400). +- Временное устройство удалено; данные пользователя (группа PRIVATE, `MSK_Home`, `OZ_Dacha`) не затронуты. + +## Не проверено / замечания +- Примечание не выводится в самой таблице (чтобы не менять утверждённый вид) — только подсказка; если нужно видеть его постоянно (например, отдельной строкой или колонкой), это отдельное решение по макету. diff --git a/docs/changes/009-dialog-label-alignment/plan.md b/docs/changes/009-dialog-label-alignment/plan.md new file mode 100644 index 0000000..f740fa1 --- /dev/null +++ b/docs/changes/009-dialog-label-alignment/plan.md @@ -0,0 +1,5 @@ +# План: 009 — выравнивание полей в окне изменения устройства + +Баг (скриншот `alignment_ui_device_edit_bug.png`): в окне изменения поле «Пароль» ниже поля «Пользователь». +Причина: подсказка «пусто — не менять» была отдельным flex-элементом и добавляла строку только над полем пароля. В макете подсказка стоит в той же строке, что и подпись. +Решение: подпись и подсказка в одном `` (`_device_form.html`), `.lbl small { font-size: inherit }` (`style.css`). diff --git a/docs/changes/009-dialog-label-alignment/summary.md b/docs/changes/009-dialog-label-alignment/summary.md new file mode 100644 index 0000000..bace935 --- /dev/null +++ b/docs/changes/009-dialog-label-alignment/summary.md @@ -0,0 +1,5 @@ +# Итоги: 009 — выравнивание полей в окне устройства + +- Подсказки у «Название», «Примечание», «Пароль» теперь в строке подписи (как в макете); поля в рядах выровнены. +- Проверено в браузере: верх полей «Пользователь» и «Пароль» совпадает (488/488), «Адрес» и «Порт» тоже (413/413); в окнах добавления и изменения. +- Побочно: меню строки корректно открывается и при узком окне (1000 px), клип в прокручиваемой таблице не возникает. diff --git a/docs/changes/010-export-show-sensitive/plan.md b/docs/changes/010-export-show-sensitive/plan.md new file mode 100644 index 0000000..ffa815b --- /dev/null +++ b/docs/changes/010-export-show-sensitive/plan.md @@ -0,0 +1,4 @@ +# План: 010 — экспорт .rsc с show-sensitive + +Запрос: добавить `show-sensitive` в создание .rsc, чтобы из копии восстанавливались все сущности, где используется пароль. +Решение: `POST /rest/export {"file": …, "show-sensitive": ""}` (`ros/operations.py::create_backup_files`). Проверено на устройстве до внедрения: без параметра в .rsc 0 секретов, с параметром — 23 (password, secret, preshared-key, private-key). Требуется политика `sensitive` у пользователя устройства (при `full` есть). Тест: запрос экспорта содержит `show-sensitive`; README — предупреждение о секретах в бакете. diff --git a/docs/changes/010-export-show-sensitive/summary.md b/docs/changes/010-export-show-sensitive/summary.md new file mode 100644 index 0000000..88e6450 --- /dev/null +++ b/docs/changes/010-export-show-sensitive/summary.md @@ -0,0 +1,7 @@ +# Итоги: 010 — экспорт .rsc с show-sensitive + +- `.rsc` создаётся с `show-sensitive`: пароли, ключи и секреты попадают в файл. +- Проверено на RB4011 (ROS 7.24.4): без параметра 0 секретных полей, с параметром 23; пробные файлы удалены с устройства. `pytest`: 9 из 9. +- Ранее созданные `.rsc` в бакете — без секретов; новые бэкапы — с секретами. +- Безопасность: файлы `.rsc` (как и `.backup` без шифрования) теперь содержат секреты открытым текстом — доступ к бакету и ссылкам скачивания должен быть ограничен. +- Полный бэкап через S3 после изменения не запускался (файл с секретами не создавался в бакете); логика проверена на устройстве и моках. diff --git a/docs/changes/011-dropdown-clipping/plan.md b/docs/changes/011-dropdown-clipping/plan.md new file mode 100644 index 0000000..8e24bc2 --- /dev/null +++ b/docs/changes/011-dropdown-clipping/plan.md @@ -0,0 +1,4 @@ +# План: 011 — обрезание выпадающих меню в узком окне + +Баг (скриншот `dropdown-menu_ui_bug.png`): при узком окне таблица получает горизонтальную прокрутку (`.table-wrap { overflow-x: auto }`), и меню «⋯» обрезается её границей. +Решение (`static/app.js`): при открытии список позиционируется от кнопки в координатах окна (`position: fixed`), не выходит за края окна и раскрывается вверх, если снизу нет места; при прокрутке и изменении размера следует за кнопкой, а если кнопка ушла за пределы окна — закрывается. Действует для всех меню (строка, групповые действия, группы, бэкапы). diff --git a/docs/changes/011-dropdown-clipping/summary.md b/docs/changes/011-dropdown-clipping/summary.md new file mode 100644 index 0000000..209911f --- /dev/null +++ b/docs/changes/011-dropdown-clipping/summary.md @@ -0,0 +1,6 @@ +# Итоги: 011 — обрезание выпадающих меню + +- Меню не обрезаются прокручиваемой таблицей; при нехватке места снизу раскрываются вверх; остаются в пределах окна. +- Проверено в браузере при 1440×900, 620×800 и 620×430: панель целиком в окне, четыре контрольные точки принадлежат панели (не обрезана), в низком окне раскрытие вверх; после прокрутки меню остаётся открытым у кнопки. Полный сценарий интерфейса (окна, выбор строк, групповые меню, фильтры, страницы) проходит без ошибок JS. +- Во время отладки исправлена собственная ошибка: первый вариант закрывал меню по любой прокрутке, а браузер сдвигает таблицу при фокусе на кнопке — меню закрывалось сразу после открытия в узком окне. +- Автотеста нет (поведение проверяется только в браузере). diff --git a/docs/changes/012-clickable-device-name/plan.md b/docs/changes/012-clickable-device-name/plan.md new file mode 100644 index 0000000..d0e9e9b --- /dev/null +++ b/docs/changes/012-clickable-device-name/plan.md @@ -0,0 +1,4 @@ +# План: 012 — кликабельное имя устройства + +Запрос: по клику на имя устройства открывается модальное окно изменения устройства. +Решение: имя в таблице — ссылка ``: с JS открывает окно (то же, что пункт меню «Изменить устройство»), без JS — запасную страницу. Внешне имя остаётся прежним (600, цвет текста); при наведении — цвет акцента и подчёркивание. Подсказка: примечание, а если его нет — «Изменить устройство». diff --git a/docs/changes/012-clickable-device-name/summary.md b/docs/changes/012-clickable-device-name/summary.md new file mode 100644 index 0000000..2c37165 --- /dev/null +++ b/docs/changes/012-clickable-device-name/summary.md @@ -0,0 +1,5 @@ +# Итоги: 012 — кликабельное имя устройства + +- Имя устройства открывает окно изменения; работает мышью и с клавиатуры (Tab → Enter), окно закрывается по Esc; адрес страницы не меняется. Пункт «Изменить устройство» в меню «⋯» сохранён. +- Автообновление таблицы не мешает: пока окно открыто, оно на паузе. +- Проверено в браузере (окно открывается с данными нужного устройства, Enter, Esc, ошибок JS нет); `pytest`: 9 из 9 (в существующий тест добавлена проверка ссылки). diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..641c4b5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = strict diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f0cf78 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +pydantic-settings>=2.4 +httpx>=0.27 +boto3>=1.34 +cryptography>=43 +jinja2>=3.1 +itsdangerous>=2.2 +python-multipart>=0.0.9 +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/ros_control.svg b/ros_control.svg new file mode 100644 index 0000000..c9d28e8 --- /dev/null +++ b/ros_control.svg @@ -0,0 +1,4 @@ + + + +
Control Server
Admin Dashboard
Endpoint API Server
(on device)
Endpoint API Server
(on device)
Endpoint API Server
(on device)
Router-1
Router-2
Router-N
JSON payload
GET / PUT / POST / DELETE
GET / PATCH / PUT / POST / DELETE
JSON Payload
JSON Payload
JSON payload render
UI Forms


  • отобразить список устройств
  • управлять списком устройств (подключить устройство, удалить устройсво, изменить)
  • статус по каждому устройству в списке показать:
    • модель
    • канал получения обновлений
    • версия ОС
    • версия FW
    • uptime
    • отметка времени запроса на создание резервной копии
  • управление резервным копированием (для каждого из подключенных устройств в отдельности)
    • создать резервную копию
    • скачать файлы резервной копии из бакета
  • резервная копия создается в 2х версиях:
    • бинарный файл резервной копии без шифрования
    • текстовый файл резервной копии в формате .rsc
    • файлы загружаются в S3 после загрузки удаляются с устройства
  • отобразить список резервных копий в бакете
  • кнопка для выбора канала обновления
  • кнопка для выполния обновления до актуальной версии в разрезе выбранного канала получения обновлений (ROS)
    • при выполнении обнолвения отправлять в перезагрузку после скачивания файла обнолвения
  • кнопка для обновления прошивки (FW)
    • при выполнении обновления отправлять в перезагрузку сразу после того, как FW обновился и показал уведомление
JSON Payload + DATA
S3 Bucket
Local Metadata DB
S3 API:
listObjectsV2 | DeleteObject | GetObject | PutObject | 
https://yandex.cloud/ru/docs/storage/s3/api-ref/
https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API#RESTAPI-HTTPMethods
DB transactions
System Design Diagram
Feature set для admin dashboard
\ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d1ebe97 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +from cryptography.fernet import Fernet + +from app import db +from app.config import get_settings + + +@pytest.fixture(autouse=True) +def env(tmp_path, monkeypatch): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/test.db") + monkeypatch.setenv("SECRET_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("API_TOKEN", "test-token") + monkeypatch.setenv("S3_BUCKET", "test-bucket") + monkeypatch.setenv("POLL_INTERVAL", "0") # фоновый опрос в тестах выключен + monkeypatch.setenv("ADMIN_USER", "admin") # не зависеть от реального .env + monkeypatch.setenv("ADMIN_PASSWORD", "pw") + get_settings.cache_clear() + db.init_db() + yield + get_settings.cache_clear() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..bdaad20 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,231 @@ +import json +from datetime import date, datetime, timezone + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app import s3, security +from app.main import create_app +from app.models import Backup, Device +from app.db import session_scope +from app.ros import operations as ros +from app.ros.client import RosClient +from app.services import backups, devices, groups, ops + + +def ros_client(handler) -> RosClient: + return RosClient("r1", 443, "admin", "pw", transport=httpx.MockTransport(handler)) + + +def test_password_encryption_roundtrip(): + token = security.encrypt("s3cret") + assert token != "s3cret" and security.decrypt(token) == "s3cret" + + +@pytest.mark.asyncio +async def test_status_parsing(): + data = { + "/rest/system/resource": {"board-name": "hAP ax3", "version": "7.15 (stable)", "uptime": "1d2h"}, + "/rest/system/routerboard": {"routerboard": "true", "model": "C53UiG", "current-firmware": "7.14", "upgrade-firmware": "7.15"}, + "/rest/system/package/update": {"channel": "stable", "installed-version": "7.15", "latest-version": "7.16", "status": "New version is available"}, + "/rest/system/package/update/check-for-updates": [], # POST: проверка обновлений + } + async with ros_client(lambda r: httpx.Response(200, json=data[r.url.path])) as c: + st = await ros.get_status(c) + assert (st["model"], st["channel"], st["fw_upgrade"], st["ros_latest"]) == ("C53UiG", "stable", "7.15", "7.16") + assert st["ros_installed"] == "7.15" and st["ros_check_error"] is None + + +@pytest.mark.asyncio +async def test_backup_flow(monkeypatch): + calls, export_body = [], {} + + def handler(r: httpx.Request) -> httpx.Response: + calls.append(f"{r.method} {r.url.path}") + if r.url.path == "/rest/export": + export_body.update(json.loads(r.content)) + if r.url.path == "/rest/file" and r.method == "GET": + return httpx.Response(200, json=[{".id": "*1", "size": "5"}]) + if r.url.path == "/rest/execute": + return httpx.Response(200, json={"ret": "aGVsbG8="}) # base64("hello") + return httpx.Response(200, json=[]) + + uploaded = {} + + async def fake_upload(path, key): # содержимое читаем до удаления временной папки + uploaded[key] = open(path, "rb").read() + + d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw") + monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler)) + monkeypatch.setattr(s3, "upload_file", fake_upload) + + await ops.run_backup(d.id) + + posts = [c for c in calls if c.startswith("POST")] + assert posts == ["POST /rest/system/backup/save", "POST /rest/export", + "POST /rest/execute", "POST /rest/execute"] # по одному блоку на файл + assert "show-sensitive" in export_body # .rsc с секретами: из него можно восстановить всё + assert calls.count("DELETE /rest/file/*1") == 2 # файлы удалены с устройства + assert sorted(uploaded.values()) == [b"hello", b"hello"] + assert all(k.startswith("backups/r1/") for k in uploaded) + with session_scope() as s: + assert s.query(Backup).one().status == "done" + assert devices.get_device(d.id).last_backup_requested_at is not None + + +def test_api_auth_and_no_password_leak(): + with TestClient(create_app()) as client: + assert client.get("/api/v1/devices").status_code == 401 + h = {"Authorization": "Bearer test-token"} + r = client.post("/api/v1/devices", headers=h, json={ + "name": "r1", "host": "10.0.0.1", "username": "admin", "password": "pw"}) + assert r.status_code == 201 and "password" not in r.text + assert client.get("/api/v1/devices", headers=h).json()[0]["name"] == "r1" + + +def test_groups_and_device_filters(): + office, store = groups.create_group("Офис"), groups.create_group("Склад") + a = devices.create_device("a", "10.0.0.1", 80, "u", "p", group_id=office.id) + b = devices.create_device("b", "10.0.0.2", 80, "u", "p", group_id=store.id) + devices.create_device("c", "10.0.0.3", 80, "u", "p") + with session_scope() as s: + s.get(Device, a.id).online = True + s.get(Device, a.id).status_json = json.dumps({"ros_installed": "7.1", "ros_latest": "7.2", "channel": "stable"}) + s.get(Device, b.id).online = False + + def names(**kw): + return sorted(d.name for d in devices.filter_devices(devices.list_devices(), **kw)) + + assert names(group=str(office.id)) == ["a"] and names(group="none") == ["c"] + assert names(status="offline") == ["b", "c"] and names(updates="ros") == ["a"] + assert names(q="10.0.0.2") == ["b"] and names(channel="stable") == ["a"] + + groups.delete_group(office.id) # устройства остаются, но без группы + assert devices.get_device(a.id).group_id is None + + +@pytest.mark.asyncio +async def test_backup_filters(monkeypatch): + office = groups.create_group("Офис") + devices.create_device("a", "10.0.0.1", 80, "u", "p", group_id=office.id) + devices.create_device("c", "10.0.0.3", 80, "u", "p") + + def item(key, day): + return {"key": key, "size": 1, "last_modified": datetime(2026, 9, day, 12, tzinfo=timezone.utc)} + + items = [item("backups/a/1.backup", 10), item("backups/a/1.rsc", 10), + item("backups/c/2.rsc", 20), item("backups/gone/3.rsc", 20)] # gone — устройство удалено + + async def fake_list(device=None): + return [i for i in items if device is None or i["key"].startswith(f"backups/{device}/")] + + monkeypatch.setattr(s3, "list_backups", fake_list) + keys = lambda r: [i["key"] for i in r] + assert keys(await backups.list_backups(group=str(office.id))) == ["backups/a/1.backup", "backups/a/1.rsc"] + assert keys(await backups.list_backups(group="none", kind="rsc")) == ["backups/c/2.rsc", "backups/gone/3.rsc"] + assert keys(await backups.list_backups(date_from=date(2026, 9, 15))) == ["backups/c/2.rsc", "backups/gone/3.rsc"] + + +@pytest.mark.asyncio +async def test_firmware_reboots_on_log_message(): + """Перезагрузка — по записи в журнале (current-firmware до неё не меняется).""" + def run(log_at_poll, old_entry_time): + calls, polls = [], {"n": 0} + + def handler(r: httpx.Request) -> httpx.Response: + calls.append(f"{r.method} {r.url.path}") + data = { + "/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.22", "upgrade-firmware": "7.24"}, + "/rest/system/clock": {"date": "2026-09-19", "time": "12:00:00"}, + "/rest/system/resource": {"uptime": "1h"}, # загрузка была в 11:00 + } + if r.url.path == "/rest/log": + polls["n"] += 1 + log = [{".id": "*1", "time": old_entry_time, "message": ros.FW_DONE_MSG}] + if polls["n"] >= log_at_poll: + log.append({".id": "*9", "time": "2026-09-19 12:00:00", "message": ros.FW_DONE_MSG}) + return httpx.Response(200, json=log) + return httpx.Response(200, json=data.get(r.url.path, [])) + + async def go(): + async with ros_client(handler) as c: + return await ros.upgrade_firmware(c, wait_seconds=10, poll=0) + return calls, go() + + # 1) новая запись появилась на 3-м чтении журнала -> команда upgrade, затем сразу reboot + calls, coro = run(3, "2026-09-19 08:00:00") + await coro + assert calls.index("POST /rest/system/routerboard/upgrade") < calls.index("POST /rest/system/reboot") == len(calls) - 1 + + # 2) запись новее последней загрузки уже есть (прошивка записана, перезагрузки не было) -> сразу reboot + calls, coro = run(99, "2026-09-19 11:30:00") + await coro + assert "POST /rest/system/routerboard/upgrade" not in calls and calls[-1] == "POST /rest/system/reboot" + + +def test_create_device_via_ui_binds_group(monkeypatch): + """Добавление устройства из окна: привязка к существующей группе и создание группы «на месте».""" + async def no_poll(_device_id): # без обращения к реальному устройству + return None + + monkeypatch.setattr(ops, "refresh_status", no_poll) + office = groups.create_group("Офис") + form = dict(host="10.0.0.1", port="80", username="u", password="p") + htmx = {"HX-Request": "true"} + with TestClient(create_app()) as c: + c.post("/login", data={"username": "admin", "password": "pw"}) + # 1) существующая группа + r = c.post("/devices/new", data={**form, "name": "a", "group_id": str(office.id), "note": " Серверная, 2 этаж "}, + follow_redirects=False) + assert r.status_code == 303 + # имя устройства в таблице открывает окно изменения (без JS — запасную страницу) + page = c.get("/").text + assert 'class="dev-link" href="/devices/1/edit" hx-get="/ui/dialog/device/1"' in page + # 2) «Новая группа…» из окна: группа создаётся и привязывается, окно просит обновить страницу + r = c.post("/devices/new", data={**form, "name": "b", "group_id": "__new__", "new_group": "Склад"}, headers=htmx) + assert r.headers["HX-Refresh"] == "true" + # 3) «Новая группа…» без названия: ошибка в окне, устройство не создаётся + r = c.post("/devices/new", data={**form, "name": "c", "group_id": "__new__", "new_group": ""}, headers=htmx) + assert "Введите название новой группы" in r.text and 'id="modal-title"' in r.text + + # окна групп открываются и показывают ошибку (дубликат) внутри окна + assert "Новая группа" in c.get("/ui/dialog/group").text + r = c.post("/groups/new", data={"name": "Склад"}, headers=htmx) + assert "уже существует" in r.text and 'id="modal-title"' in r.text + + by_name = {d.name: d for d in devices.list_devices()} + store = next(g for g in groups.list_groups() if g["name"] == "Склад") + assert by_name["a"].group_id == office.id and by_name["b"].group_id == store["id"] and "c" not in by_name + assert by_name["a"].note == "Серверная, 2 этаж" and by_name["b"].note is None # пробелы обрезаются, пусто -> None + + +@pytest.mark.asyncio +async def test_poll_marks_offline_and_recovers(monkeypatch): + """Фоновый опрос: недоступность видна сразу; после возвращения — полный опрос (версии обновились).""" + d = devices.create_device("r1", "10.0.0.1", 80, "u", "p") + up = {"on": False} + data = { + "/rest/system/resource": {"version": "7.24.4 (stable)", "uptime": "1m", "board-name": "hAP"}, + "/rest/system/routerboard": {"routerboard": "false"}, + "/rest/system/package/update": {"channel": "stable", "installed-version": "7.24.4", "latest-version": "7.24.4"}, + } + + def handler(r: httpx.Request) -> httpx.Response: + if not up["on"]: + raise httpx.ConnectError("connection refused") + return httpx.Response(200, json=data.get(r.url.path, [])) + + monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler)) + await ops.poll_device(d.id) + assert devices.get_device(d.id).online is False + + up["on"] = True + await ops.poll_device(d.id) # был offline -> полный опрос + dev = devices.get_device(d.id) + assert dev.online is True and dev.status["ros_installed"] == "7.24.4" + + data["/rest/system/resource"]["uptime"] = "2m" + await ops.poll_device(d.id) # онлайн -> лёгкий опрос обновляет uptime, остальное сохраняется + dev = devices.get_device(d.id) + assert dev.status["uptime"] == "2m" and dev.status["ros_latest"] == "7.24.4"