ros_control: централизованное управление парком MikroTik RouterOS
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 <noreply@anthropic.com>
This commit is contained in:
commit
4c1841b61b
85 files changed
+3579
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
# Твоя роль
|
||||||
|
|
||||||
|
- DevOps инженер
|
||||||
|
- Разработчик Backend
|
||||||
|
- Архитектор информационных систем
|
||||||
|
- Архитектор корпоративной сети
|
||||||
|
|
||||||
|
# Стиль общения
|
||||||
|
|
||||||
|
- профессиональный, но без жаргона
|
||||||
|
|
||||||
|
# Стиль ответов
|
||||||
|
|
||||||
|
- максимально емкие и содержательные
|
||||||
|
- не проваливайся в лишние детали, если это явно не было запрошено
|
||||||
|
|
||||||
|
# Создание артефактов
|
||||||
|
|
||||||
|
- На каждое новое изменение должен быть артефакт в .md файле
|
||||||
|
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
|
||||||
|
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
|
||||||
|
- каждое изменение дополняет или обновляет README.md
|
||||||
|
|
||||||
|
# Автотесты
|
||||||
|
|
||||||
|
- минимальное количество тестов
|
||||||
|
|
||||||
|
# Окружение для разработки
|
||||||
|
|
||||||
|
- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
venv
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
*.svg
|
||||||
@@ -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 в опросе, секунд
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
venv/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
+10
@@ -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"]
|
||||||
@@ -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 <API_TOKEN>`. Интерактивная документация — `/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` — клик по имени устройства открывает окно изменения.
|
||||||
Whitespace-only changes.
Whitespace-only changes.
+258
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+60
@@ -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()
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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://<host>:<port>/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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
Whitespace-only changes.
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,15 @@
|
|||||||
|
"""Иконки интерфейса: inline SVG (stroke), общий набор с макетом."""
|
||||||
|
ICONS = {
|
||||||
|
"search": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||||||
|
"chev": '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
"plus": '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
"refresh": '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>',
|
||||||
|
"more": '<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>',
|
||||||
|
"check": '<path d="m5 12 5 5L20 7"/>',
|
||||||
|
"up": '<path d="M12 19V5M6 11l6-6 6 6"/>',
|
||||||
|
"x": '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
|
"archive": '<rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10h14V9M10 13h4"/>',
|
||||||
|
"download": '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
|
||||||
|
"router": '<rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 17h.01M11 17h.01M8 14l-2-6M16 14l2-6"/>',
|
||||||
|
"arrow": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return 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(/<head(\s[^>]*)?>[\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('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");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.toStrinLine truncated
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ stats.total }} {{ stats.total|plural("устройство", "устройства", "устройств") }} · {{ stats.online }} online · {{ stats.offline }} offline
|
||||||
@@ -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' %}
|
||||||
|
<form method="post" action="{{ action }}" {% if in_modal %}hx-post="{{ action }}" hx-target="#modal-body"{% endif %}>
|
||||||
|
<header class="modal-head">
|
||||||
|
<h2 id="modal-title">{{ "Изменить устройство" if edit else "Новое устройство" }}</h2>
|
||||||
|
{% if in_modal %}<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>{% endif %}
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
|
||||||
|
<label class="lbl"><span>Название <small>латиница, цифры, «.», «_», «-»; попадает в ключи бэкапов</small></span>
|
||||||
|
<input class="input" name="name" value="{{ v.name }}" maxlength="64" required autofocus></label>
|
||||||
|
<label class="lbl"><span>Примечание <small>необязательно</small></span>
|
||||||
|
<textarea class="input" name="note" rows="3" maxlength="500" placeholder="Например: серверная, 2 этаж; ответственный — дежурный инженер">{{ v.note }}</textarea></label>
|
||||||
|
<div class="form-row host">
|
||||||
|
<label class="lbl">Адрес<input class="input" name="host" value="{{ v.host }}" required></label>
|
||||||
|
<label class="lbl">Порт<input class="input" name="port" type="number" min="1" max="65535" value="{{ v.port }}" required></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="lbl">Пользователь<input class="input" name="username" value="{{ v.username }}" required></label>
|
||||||
|
<label class="lbl"><span>Пароль {% if edit %}<small>пусто — не менять</small>{% endif %}</span>
|
||||||
|
<input class="input" name="password" type="password" autocomplete="new-password" {{ "" if edit else "required" }}></label>
|
||||||
|
</div>
|
||||||
|
<label class="lbl">Группа
|
||||||
|
<select class="field" name="group_id" data-newgroup style="width:100%">
|
||||||
|
<option value="">— без группы —</option>
|
||||||
|
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if v.group_id == g.id|string }}>{{ g.name }}</option>{% endfor %}
|
||||||
|
<option value="__new__" {{ "selected" if v.group_id == "__new__" }}>+ Новая группа…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="lbl" id="new-group-field" {{ "" if v.group_id == "__new__" else "hidden" }}>Название новой группы
|
||||||
|
<input class="input" name="new_group" value="{{ v.new_group }}" maxlength="64"></label>
|
||||||
|
<label class="check"><input name="use_tls" type="checkbox" {{ "checked" if v.use_tls }}>
|
||||||
|
<span>HTTPS (сервис www-ssl)<small>Порт обычно 443; без HTTPS — сервис www, порт 80</small></span></label>
|
||||||
|
<div class="note" id="http-note" {{ "hidden" if v.use_tls }}>Без HTTPS логин и пароль передаются по сети открытым текстом — только для доверенной сети.</div>
|
||||||
|
<label class="check"><input name="verify_tls" type="checkbox" {{ "checked" if v.verify_tls }}>
|
||||||
|
<span>Проверять TLS-сертификат<small>Отключите для самоподписанных сертификатов</small></span></label>
|
||||||
|
</div>
|
||||||
|
<footer class="modal-foot">
|
||||||
|
{% if edit %}
|
||||||
|
{% if in_modal %}<button type="button" class="btn danger" hx-post="/devices/{{ device.id }}/delete"
|
||||||
|
hx-confirm="Удалить устройство {{ device.name }} из списка? Бэкапы в бакете сохранятся.">Удалить устройство</button>
|
||||||
|
{% else %}<button class="btn danger" formaction="/devices/{{ device.id }}/delete" formnovalidate
|
||||||
|
onclick="return confirm('Удалить устройство {{ device.name }} из списка? Бэкапы в бакете сохранятся.')">Удалить устройство</button>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="grow"></span>
|
||||||
|
{% if in_modal %}<button type="button" class="btn ghost-n" data-close>Отмена</button>{% else %}<a class="btn ghost-n" href="/">Отмена</a>{% endif %}
|
||||||
|
<button class="btn primary">{{ "Сохранить" if edit else "Добавить устройство" }}</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{% import "_ui.html" as ui %}
|
||||||
|
{% if oob %}<div class="sub" id="dev-sub" hx-swap-oob="true">{% include "_dev_sub.html" %}</div>{% endif %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="grid">
|
||||||
|
<colgroup><col style="width:44px"><col><col style="width:110px"><col style="width:110px"><col style="width:100px"><col style="width:120px"><col style="width:90px"><col style="width:120px"><col style="width:100px"><col style="width:150px"><col style="width:52px"></colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="check-all" aria-label="Выбрать все"></th>
|
||||||
|
<th>Устройство</th><th>Группа</th><th>Канал</th><th>ROS</th><th>Upgrade ROS</th><th>FW</th><th>Upgrade FW</th><th>Uptime</th><th>Запрос бэкапа (UTC)</th><th><span class="sr-only">Действия</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% 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 %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" aria-label="Выбрать {{ d.name }}"></td>
|
||||||
|
<td>
|
||||||
|
<div class="dev">
|
||||||
|
<span class="dot {{ 'on' if d.online else ('off' if down else '') }}" title="{{ d.last_error or ('online, опрошено ' ~ (d.status_at|dt)) }}"></span>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="dev-name"><a class="dev-link" href="/devices/{{ d.id }}/edit" hx-get="/ui/dialog/device/{{ d.id }}" hx-target="#modal-body"
|
||||||
|
title="{{ d.note or 'Изменить устройство' }}">{{ d.name }}</a></div>
|
||||||
|
{% if down %}<div class="dev-sub bad" title="{{ d.last_error }}">Нет соединения</div>
|
||||||
|
{% else %}<div class="dev-sub">{{ d.host }}:{{ d.port }}{% if st.model %} · {{ st.model }}{% endif %}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ group_names.get(d.group_id) or "—" }}</td>
|
||||||
|
<td>{% if down %}<span class="dash">—</span>{% else %}{{ st.channel or "—" }}{% endif %}</td>
|
||||||
|
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.ros_installed or st.ros_version or "—" }}{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if down or not (st.ros_latest and st.ros_installed) %}<span class="dash" title="{{ st.ros_check_error or 'Проверка обновлений ещё не выполнялась' }}">—</span>
|
||||||
|
{% elif ros_new %}<span class="badge warn" title="Доступно обновление ROS до {{ st.ros_latest }}">{{ ui.icon("up", 12, 2.2) }}{{ st.ros_latest }}</span>
|
||||||
|
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if down or not (st.fw_current and st.fw_upgrade) %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
|
||||||
|
{% elif fw_new %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
|
||||||
|
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
|
||||||
|
<td class="c-small">{{ d.last_backup_requested_at|dt }}</td>
|
||||||
|
<td class="c-menu">
|
||||||
|
<details class="menu right">
|
||||||
|
<summary class="btn ghost-n icon" aria-label="Действия для {{ d.name }}" title="Действия">{{ ui.icon("more") }}</summary>
|
||||||
|
<div class="menu-panel w270">
|
||||||
|
<button type="button" class="mi" hx-post="/ui/devices/{{ d.id }}/refresh" hx-include="#dev-filters" hx-target="#devices">Обновить статус</button>
|
||||||
|
<button type="button" class="mi" hx-post="/ui/devices/{{ d.id }}/backup" hx-target="#jobs">Создать бэкап</button>
|
||||||
|
<hr>
|
||||||
|
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/ros_update" hx-target="#jobs"
|
||||||
|
hx-confirm="Обновить ROS на {{ d.name }}? Устройство перезагрузится после скачивания.">Обновить ROS<small>Перезагрузка после скачивания</small></button>
|
||||||
|
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/fw_update" hx-target="#jobs"
|
||||||
|
hx-confirm="Обновить прошивку на {{ d.name }}? Устройство перезагрузится сразу после обновления.">Обновить FW<small>Перезагрузка сразу после обновления</small></button>
|
||||||
|
<hr>
|
||||||
|
<details class="sub">
|
||||||
|
<summary class="mi">Сменить канал…</summary>
|
||||||
|
<div class="sub-list">
|
||||||
|
{% for c in channels %}<button type="button" class="mi {{ 'on' if c == st.channel }}" hx-post="/ui/devices/{{ d.id }}/channel" hx-vals='{"channel": "{{ c }}"}' hx-include="#dev-filters" hx-target="#devices">{{ c }}</button>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" class="mi" hx-get="/ui/dialog/device/{{ d.id }}" hx-target="#modal-body">Изменить устройство</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="11" class="empty">{{ "Нет устройств, подходящих под фильтр." if filtered else "Устройств пока нет. Добавьте первое." }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="tfoot">Показано {{ devices|length }} из {{ total }} {{ total|plural("устройства", "устройств", "устройств") }}</div>
|
||||||
@@ -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' %}
|
||||||
|
<form method="post" action="{{ action }}" {% if in_modal %}hx-post="{{ action }}" hx-target="#modal-body"{% endif %}>
|
||||||
|
<header class="modal-head">
|
||||||
|
<h2 id="modal-title">{{ "Переименовать группу" if edit else "Новая группа" }}</h2>
|
||||||
|
{% if in_modal %}<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>{% endif %}
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
|
||||||
|
<label class="lbl">Название<input class="input" name="name" value="{{ gname }}" maxlength="64" required autofocus></label>
|
||||||
|
</div>
|
||||||
|
<footer class="modal-foot">
|
||||||
|
<span class="grow"></span>
|
||||||
|
{% if in_modal %}<button type="button" class="btn ghost-n" data-close>Отмена</button>{% else %}<a class="btn ghost-n" href="/groups">Отмена</a>{% endif %}
|
||||||
|
<button class="btn primary">{{ "Сохранить" if edit else "Создать группу" }}</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
@@ -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 %}
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Задачи</h2>
|
||||||
|
{% if running %}<span class="badge run">{{ running }} выполняется</span>{% endif %}
|
||||||
|
{% if done %}<span class="badge">{{ done }} завершено</span>{% endif %}
|
||||||
|
{% if failed %}<span class="badge bad">{{ failed }} с ошибкой</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if flash %}<p class="err">{{ flash }}</p>{% endif %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="grid jobs">
|
||||||
|
<colgroup><col style="width:56px"><col style="width:16%"><col style="width:16%"><col style="width:130px"><col><col style="width:150px"></colgroup>
|
||||||
|
<thead><tr><th>#</th><th>Устройство</th><th>Операция</th><th>Статус</th><th>Сообщение</th><th>Создана (UTC)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for j in jobs %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ j.id }}</td>
|
||||||
|
<td style="font-weight:500">{{ j.device_name }}</td>
|
||||||
|
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW"}.get(j.type, j.type) }}</td>
|
||||||
|
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
|
||||||
|
<td class="msg">{{ j.message }}</td>
|
||||||
|
<td class="c-small">{{ j.created_at|dt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="empty">Задач пока нет.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{# Общие макросы: иконки (inline SVG, stroke) #}
|
||||||
|
{% macro icon(name, size=16, sw=1.8) -%}
|
||||||
|
<svg class="ico" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="{{ sw }}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{{ ICONS[name]|safe }}</svg>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "_ui.html" as ui %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Резервные копии</h1>
|
||||||
|
<div class="sub">Бакет {{ bucket }} · {{ stats.total }} {{ stats.total|plural("файл", "файла", "файлов") }} · {{ stats.size|size }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><a class="btn secondary" href="/backups?{{ request.url.query }}">{{ ui.icon("refresh") }}Обновить список</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="tools">
|
||||||
|
<form method="get" action="/backups">
|
||||||
|
<select class="field {{ 'on' if flt.group }}" name="group" onchange="this.form.submit()" aria-label="Группа" style="width:170px">
|
||||||
|
<option value="">Группа: любая</option>
|
||||||
|
<option value="none" {{ "selected" if flt.group == "none" }}>Группа: без группы</option>
|
||||||
|
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if flt.group == g.id|string }}>Группа: {{ g.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select class="field {{ 'on' if flt.device }}" name="device" onchange="this.form.submit()" aria-label="Устройство" style="width:190px">
|
||||||
|
<option value="">Устройство: любое</option>
|
||||||
|
{% for d in devices %}<option value="{{ d.name }}" {{ "selected" if d.name == flt.device }}>Устройство: {{ d.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select class="field {{ 'on' if flt.kind }}" name="kind" onchange="this.form.submit()" aria-label="Тип файла" style="width:150px">
|
||||||
|
<option value="">Тип: любой</option>
|
||||||
|
<option value="backup" {{ "selected" if flt.kind == "backup" }}>Тип: .backup</option>
|
||||||
|
<option value="rsc" {{ "selected" if flt.kind == "rsc" }}>Тип: .rsc</option>
|
||||||
|
</select>
|
||||||
|
<label class="datefield">с <input type="date" name="date_from" value="{{ flt.date_from }}" onchange="this.form.submit()" aria-label="Дата с"></label>
|
||||||
|
<label class="datefield">по <input type="date" name="date_to" value="{{ flt.date_to }}" onchange="this.form.submit()" aria-label="Дата по"></label>
|
||||||
|
<label class="search">{{ ui.icon("search") }}<input type="search" name="q" value="{{ flt.q }}" placeholder="Имя файла" aria-label="Поиск по имени файла" style="width:220px"></label>
|
||||||
|
<span class="grow"></span>
|
||||||
|
{% if flt_active %}<a class="btn ghost" href="/backups">Сбросить ({{ flt_active }})</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if error %}<div class="form-err" role="alert" style="margin:0 16px 16px">Ошибка S3: {{ error }}</div>{% endif %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="grid files">
|
||||||
|
<colgroup><col style="width:20%"><col style="width:140px"><col style="width:100px"><col><col style="width:100px"><col style="width:170px"><col style="width:104px"></colgroup>
|
||||||
|
<thead><tr><th>Устройство</th><th>Группа</th><th>Тип</th><th>Файл</th><th>Размер</th><th>Изменён (UTC)</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for i in items %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600">{{ i.device or "—" }}</td>
|
||||||
|
<td class="muted">{{ i.group or "—" }}</td>
|
||||||
|
<td><span class="badge">{{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }}</span></td>
|
||||||
|
<td class="c-mono" title="{{ i.key }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ i.key.split("/")[-1] }}</td>
|
||||||
|
<td class="c-small">{{ i.size|size }}</td>
|
||||||
|
<td class="c-small">{{ i.last_modified|dt }}</td>
|
||||||
|
<td class="c-menu">
|
||||||
|
<a class="btn ghost-n icon" href="/backups/download?key={{ i.key|urlencode }}" aria-label="Скачать {{ i.key.split('/')[-1] }}" title="Скачать">{{ ui.icon("download") }}</a>
|
||||||
|
<details class="menu right">
|
||||||
|
<summary class="btn ghost-n icon" aria-label="Действия" title="Действия">{{ ui.icon("more") }}</summary>
|
||||||
|
<div class="menu-panel w200">
|
||||||
|
<form method="post" action="/backups/delete" onsubmit="return confirm('Удалить файл из бакета?')">
|
||||||
|
<input type="hidden" name="key" value="{{ i.key }}"><button class="mi bad">Удалить файл</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="empty">Файлов не найдено.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="tfoot">Найдено {{ items|length }} из {{ stats.total }} {{ stats.total|plural("файла", "файлов", "файлов") }}</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% import "_ui.html" as ui %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}ros_control{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if request.session.get("user") %}
|
||||||
|
{% set nav = [("Устройства", "/", "devices"), ("Группы", "/groups", "groups"), ("Резервные копии", "/backups", "backups")] %}
|
||||||
|
<header class="app-header">
|
||||||
|
<a class="brand" href="/"><span class="brand-logo">{{ ui.icon("router") }}</span>ros_control</a>
|
||||||
|
<nav class="nav" aria-label="Разделы">
|
||||||
|
{% for title, href, key in nav %}<a href="{{ href }}" class="{{ 'on' if section == key }}">{{ title }}</a>{% endfor %}
|
||||||
|
</nav>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span class="user">{{ request.session.get("user") }}</span>
|
||||||
|
<form method="post" action="/logout"><button class="btn ghost-n">Выйти</button></form>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
<main class="container">{% block content %}{% endblock %}</main>
|
||||||
|
<dialog id="modal" class="modal" aria-labelledby="modal-title"><div id="modal-body"></div></dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "_ui.html" as ui %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Устройства</h1>
|
||||||
|
<div class="sub" id="dev-sub">{% include "_dev_sub.html" %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn secondary" hx-post="/ui/refresh" hx-include="#dev-filters" hx-target="#devices" hx-indicator="#busy">{{ ui.icon("refresh") }}Обновить статус</button>
|
||||||
|
<button type="button" class="btn primary" hx-get="/ui/dialog/device{{ '?group=' ~ flt.group if flt.group.isdigit() }}" hx-target="#modal-body">{{ ui.icon("plus") }}Добавить устройство</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Группы устройств">
|
||||||
|
{% for t in tabs %}<a class="tab {{ 'on' if t.active }}" href="{{ t.href }}">{{ t.label }} <span class="count">{{ t.count }}</span></a>{% endfor %}
|
||||||
|
<span class="grow"></span>
|
||||||
|
<a class="tab-link" href="/groups">Управление группами {{ ui.icon("arrow", 14) }}</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form id="move-form" method="post" action="/ui/move" hidden><input type="hidden" name="next" value="{{ page_url }}"></form>
|
||||||
|
|
||||||
|
<div class="card" id="dev-card">
|
||||||
|
<div class="tools" id="tools-filters">
|
||||||
|
<form id="dev-filters" hx-get="/ui/devices" hx-target="#devices" hx-trigger="input delay:300ms, change" onsubmit="return false">
|
||||||
|
<input type="hidden" name="f_group" value="{{ flt.group }}">
|
||||||
|
<label class="search">{{ ui.icon("search") }}<input type="search" name="f_q" value="{{ flt.q }}" placeholder="Поиск: имя, адрес, модель" aria-label="Поиск: имя, адрес, модель"></label>
|
||||||
|
<select class="field {{ 'on' if flt.status }}" name="f_status" aria-label="Статус" style="width:150px">
|
||||||
|
<option value="">Статус: любой</option>
|
||||||
|
<option value="online" {{ 'selected' if flt.status == 'online' }}>Статус: online</option>
|
||||||
|
<option value="offline" {{ 'selected' if flt.status == 'offline' }}>Статус: offline</option>
|
||||||
|
</select>
|
||||||
|
<select class="field {{ 'on' if flt.updates }}" name="f_updates" aria-label="Обновления" style="width:190px">
|
||||||
|
<option value="">Обновления: любые</option>
|
||||||
|
<option value="any" {{ 'selected' if flt.updates == 'any' }}>Есть обновление</option>
|
||||||
|
<option value="ros" {{ 'selected' if flt.updates == 'ros' }}>Есть обновление ROS</option>
|
||||||
|
<option value="fw" {{ 'selected' if flt.updates == 'fw' }}>Есть обновление FW</option>
|
||||||
|
<option value="none" {{ 'selected' if flt.updates == 'none' }}>Всё актуально</option>
|
||||||
|
</select>
|
||||||
|
<select class="field {{ 'on' if flt.channel }}" name="f_channel" aria-label="Канал" style="width:150px">
|
||||||
|
<option value="">Канал: любой</option>
|
||||||
|
{% for c in channels %}<option value="{{ c }}" {{ 'selected' if flt.channel == c }}>Канал: {{ c }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<a id="filters-reset" class="btn ghost" href="{{ '/?f_group=' ~ flt.group if flt.group else '/' }}" {{ 'hidden' if not flt_active }}>Сбросить ({{ flt_active }})</a>
|
||||||
|
<span id="busy" class="busy htmx-indicator">…</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tools bulk" id="tools-bulk" hidden>
|
||||||
|
<span class="sel-count">Выбрано: <span id="sel-count">0</span></span>
|
||||||
|
<button type="button" class="btn secondary" hx-post="/ui/batch/backup" hx-include="[name=device_ids]:checked" hx-target="#jobs">{{ ui.icon("archive") }}Создать бэкап</button>
|
||||||
|
<button type="button" class="btn secondary" hx-post="/ui/refresh" hx-include="[name=device_ids]:checked, #dev-filters" hx-target="#devices">{{ ui.icon("refresh") }}Обновить статус</button>
|
||||||
|
<details class="menu">
|
||||||
|
<summary class="btn secondary">Обновление {{ ui.icon("chev", 14) }}</summary>
|
||||||
|
<div class="menu-panel w330">
|
||||||
|
<button type="button" class="mi warn" hx-post="/ui/batch/ros_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
|
||||||
|
hx-confirm="Обновить ROS на выбранных устройствах? Они перезагрузятся после скачивания.">Обновить ROS до актуальной версии<small>Устройство перезагрузится после скачивания</small></button>
|
||||||
|
<button type="button" class="mi warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
|
||||||
|
hx-confirm="Обновить прошивку на выбранных устройствах? Они перезагрузятся сразу после обновления.">Обновить прошивку (FW)<small>Устройство перезагрузится сразу после обновления</small></button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="menu">
|
||||||
|
<summary class="btn secondary">Канал {{ ui.icon("chev", 14) }}</summary>
|
||||||
|
<div class="menu-panel w200">
|
||||||
|
{% for c in channels %}<button type="button" class="mi" hx-post="/ui/batch/channel" hx-vals='{"channel": "{{ c }}"}' hx-include="[name=device_ids]:checked, #dev-filters" hx-target="#devices">{{ c }}</button>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="menu">
|
||||||
|
<summary class="btn secondary">В группу {{ ui.icon("chev", 14) }}</summary>
|
||||||
|
<div class="menu-panel w200">
|
||||||
|
{% for g in groups %}<button type="submit" form="move-form" name="group_id" value="{{ g.id }}" class="mi">{{ g.name }}</button>{% endfor %}
|
||||||
|
{% if groups %}<hr>{% endif %}
|
||||||
|
<button type="submit" form="move-form" name="group_id" value="" class="mi">Без группы</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button type="button" id="clear-sel" class="btn ghost-n">{{ ui.icon("x") }}Снять выбор</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="devices">{% include "_devices.html" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="jobs" hx-get="/ui/jobs" hx-trigger="every 3s" hx-swap="innerHTML">{% include "_jobs.html" %}</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card page-card">{% include "_device_form.html" %}</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "_ui.html" as ui %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Группы</h1>
|
||||||
|
<div class="sub">{{ groups|length }} {{ groups|length|plural("группа", "группы", "групп") }} · {{ total }} {{ total|plural("устройство", "устройства", "устройств") }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn primary" hx-get="/ui/dialog/group" hx-target="#modal-body">{{ ui.icon("plus") }}Создать группу</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if error %}<div class="form-err" role="alert" style="margin-bottom:16px">{{ error }}</div>{% endif %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="grid groups">
|
||||||
|
<colgroup><col><col style="width:200px"><col style="width:120px"></colgroup>
|
||||||
|
<thead><tr><th>Название</th><th>Устройства</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in groups %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600">{{ g.name }}</td>
|
||||||
|
<td><a href="/?f_group={{ g.id }}" style="font-weight:500">{{ g.device_count }} {{ g.device_count|plural("устройство", "устройства", "устройств") }}</a></td>
|
||||||
|
<td class="c-menu">
|
||||||
|
<details class="menu right">
|
||||||
|
<summary class="btn ghost-n icon" aria-label="Действия для группы {{ g.name }}" title="Действия">{{ ui.icon("more") }}</summary>
|
||||||
|
<div class="menu-panel w200">
|
||||||
|
<button type="button" class="mi" hx-get="/ui/dialog/group/{{ g.id }}" hx-target="#modal-body">Переименовать</button>
|
||||||
|
<button type="button" class="mi bad" hx-post="/groups/{{ g.id }}/delete"
|
||||||
|
hx-confirm="Удалить группу «{{ g.name }}»? Устройства останутся и станут «Без группы».">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="empty">Групп пока нет. Создайте первую.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted" style="font-weight:600">Без группы</td>
|
||||||
|
<td><a href="/?f_group=none" style="font-weight:500">{{ ungrouped }} {{ ungrouped|plural("устройство", "устройства", "устройств") }}</a></td>
|
||||||
|
<td class="c-menu c-small">системная</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" action="/login" class="card login">
|
||||||
|
<div class="modal-body">
|
||||||
|
<h1>Вход</h1>
|
||||||
|
{% if error %}<div class="form-err">{{ error }}</div>{% endif %}
|
||||||
|
<label class="lbl">Логин <input class="input" name="username" autofocus required></label>
|
||||||
|
<label class="lbl">Пароль <input class="input" name="password" type="password" required></label>
|
||||||
|
<button class="btn primary">Войти</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -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:
|
||||||
@@ -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://<host>/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/<device>/<timestamp>.backup|.rsc`;
|
||||||
|
- после успешной загрузки — `DELETE /rest/file/<id>` на устройстве; время запроса пишется в БД;
|
||||||
|
- список — `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.
|
||||||
@@ -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`.
|
||||||
@@ -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, проверка статуса и бэкапа, диагностика прав пользователя.
|
||||||
@@ -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.
|
||||||
@@ -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/<id>` для файлов >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`.
|
||||||
@@ -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 (перезагрузка устройства), групповые операции над несколькими устройствами.
|
||||||
@@ -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) → «—».
|
||||||
|
- Колонка «Действия» → компактная колонка с кнопкой `⋯`; меню на `<details class="menu"><summary aria-label="Действия">⋯</summary>…</details>` (без внешних библиотек). Пункты те же, что были кнопками: Обновить статус, Бэкап, Обновить 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` (см. пункт проверки выше).
|
||||||
@@ -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 не выполнялся (перезагрузка устройства) — проверялось только отображение.
|
||||||
@@ -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: устройство, переименованное после бэкапа, потеряет связь с группой в списке бэкапов (старые файлы останутся под прежним именем).
|
||||||
@@ -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/<устройство>/…`): бэкапы удалённого или переименованного устройства
|
||||||
|
отображаются с группой «—» и попадают в «Без группы».
|
||||||
|
- Фильтр по обновлениям использует кэш статуса; у ещё не опрошенных устройств поля пусты.
|
||||||
@@ -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` (раздел «Баги») и утверждённый план в истории работ.
|
||||||
@@ -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`, «Скачать» и «⋯» в строке), вход.
|
||||||
|
- Окна (`<dialog>` + 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 в светлой).
|
||||||
|
|
||||||
|
## Отличия от макета
|
||||||
|
- Календарь в фильтре дат — нативный `<input type="date">`: формат зависит от локали браузера (в макете ISO).
|
||||||
|
- Фильтры вкладок и списков в приложении работают; в макете они статичны.
|
||||||
@@ -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, итоги.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# План: 008 — примечание к устройству
|
||||||
|
|
||||||
|
Запрос: комментарий владельца к листу «Окно: добавление устройства» в макете — «добавь место, где можно будет написать примечание».
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
- Макет: в окне устройства под полем «Название» — многострочное поле «Примечание» (необязательно). Прод повторяет макет.
|
||||||
|
- Приложение: `devices.note` (TEXT, до 500 символов, пробелы по краям обрезаются, пусто = нет), миграция БД, поле в форме окна и запасной страницы, `note` в API (`POST/PATCH/GET /api/v1/devices`), примечание показывается подсказкой (`title`) над именем устройства в таблице — внешний вид таблицы не меняется.
|
||||||
|
- Тест: примечание через окно (обрезка пробелов, пусто → null).
|
||||||
@@ -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`) не затронуты.
|
||||||
|
|
||||||
|
## Не проверено / замечания
|
||||||
|
- Примечание не выводится в самой таблице (чтобы не менять утверждённый вид) — только подсказка; если нужно видеть его постоянно (например, отдельной строкой или колонкой), это отдельное решение по макету.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# План: 009 — выравнивание полей в окне изменения устройства
|
||||||
|
|
||||||
|
Баг (скриншот `alignment_ui_device_edit_bug.png`): в окне изменения поле «Пароль» ниже поля «Пользователь».
|
||||||
|
Причина: подсказка «пусто — не менять» была отдельным flex-элементом и добавляла строку только над полем пароля. В макете подсказка стоит в той же строке, что и подпись.
|
||||||
|
Решение: подпись и подсказка в одном `<span>` (`_device_form.html`), `.lbl small { font-size: inherit }` (`style.css`).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Итоги: 009 — выравнивание полей в окне устройства
|
||||||
|
|
||||||
|
- Подсказки у «Название», «Примечание», «Пароль» теперь в строке подписи (как в макете); поля в рядах выровнены.
|
||||||
|
- Проверено в браузере: верх полей «Пользователь» и «Пароль» совпадает (488/488), «Адрес» и «Порт» тоже (413/413); в окнах добавления и изменения.
|
||||||
|
- Побочно: меню строки корректно открывается и при узком окне (1000 px), клип в прокручиваемой таблице не возникает.
|
||||||
@@ -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 — предупреждение о секретах в бакете.
|
||||||
@@ -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 после изменения не запускался (файл с секретами не создавался в бакете); логика проверена на устройстве и моках.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# План: 011 — обрезание выпадающих меню в узком окне
|
||||||
|
|
||||||
|
Баг (скриншот `dropdown-menu_ui_bug.png`): при узком окне таблица получает горизонтальную прокрутку (`.table-wrap { overflow-x: auto }`), и меню «⋯» обрезается её границей.
|
||||||
|
Решение (`static/app.js`): при открытии список позиционируется от кнопки в координатах окна (`position: fixed`), не выходит за края окна и раскрывается вверх, если снизу нет места; при прокрутке и изменении размера следует за кнопкой, а если кнопка ушла за пределы окна — закрывается. Действует для всех меню (строка, групповые действия, группы, бэкапы).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Итоги: 011 — обрезание выпадающих меню
|
||||||
|
|
||||||
|
- Меню не обрезаются прокручиваемой таблицей; при нехватке места снизу раскрываются вверх; остаются в пределах окна.
|
||||||
|
- Проверено в браузере при 1440×900, 620×800 и 620×430: панель целиком в окне, четыре контрольные точки принадлежат панели (не обрезана), в низком окне раскрытие вверх; после прокрутки меню остаётся открытым у кнопки. Полный сценарий интерфейса (окна, выбор строк, групповые меню, фильтры, страницы) проходит без ошибок JS.
|
||||||
|
- Во время отладки исправлена собственная ошибка: первый вариант закрывал меню по любой прокрутке, а браузер сдвигает таблицу при фокусе на кнопке — меню закрывалось сразу после открытия в узком окне.
|
||||||
|
- Автотеста нет (поведение проверяется только в браузере).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# План: 012 — кликабельное имя устройства
|
||||||
|
|
||||||
|
Запрос: по клику на имя устройства открывается модальное окно изменения устройства.
|
||||||
|
Решение: имя в таблице — ссылка `<a class="dev-link" href="/devices/{id}/edit" hx-get="/ui/dialog/device/{id}">`: с JS открывает окно (то же, что пункт меню «Изменить устройство»), без JS — запасную страницу. Внешне имя остаётся прежним (600, цвет текста); при наведении — цвет акцента и подчёркивание. Подсказка: примечание, а если его нет — «Изменить устройство».
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Итоги: 012 — кликабельное имя устройства
|
||||||
|
|
||||||
|
- Имя устройства открывает окно изменения; работает мышью и с клавиатуры (Tab → Enter), окно закрывается по Esc; адрес страницы не меняется. Пункт «Изменить устройство» в меню «⋯» сохранён.
|
||||||
|
- Автообновление таблицы не мешает: пока окно открыто, оно на паузе.
|
||||||
|
- Проверено в браузере (окно открывается с данными нужного устройства, Enter, Esc, ошибок JS нет); `pytest`: 9 из 9 (в существующий тест добавлена проверка ссылки).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = strict
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Do not edit this file with editors other than draw.io -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background: #ffffff; background-color: var(--ge-adaptive-bg, #ffffff); color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1712px" height="622px" viewBox="0 0 1712 622" id="ge-svg-Z9YZkVvMoTYfECfxNedf" content="<mxfile host="Electron" scale="1" border="10"> <diagram name="Page-1" id="iWh9hwjvaEKYWgPO58dA"> <mxGraphModel dx="2441" dy="1589" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0"> <root> <mxCell id="0" /> <mxCell id="1" parent="0" /> <mxCell id="2" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="" vertex="1"> <mxGeometry height="600" width="1200" x="-200" y="-80" as="geometry" /> </mxCell> <mxCell id="3" edge="1" parent="1" source="5" style="edgeStyle=orthogonalEdgeStyle;shape=connector;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeColor=#000000;align=center;verticalAlign=middle;fontFamily=Helvetica;fontSize=11;fontColor=default;labelBackgroundColor=default;startArrow=classic;startFill=1;endArrow=classic;endFill=1;" target="28"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="4" edge="1" parent="1" source="5" style="edgeStyle=orthogonalEdgeStyle;shape=connector;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeColor=#000000;align=center;verticalAlign=middle;fontFamily=Helvetica;fontSize=11;fontColor=default;labelBackgroundColor=default;startArrow=classic;startFill=1;endArrow=classic;endFill=1;" target="29"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="5" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Control Server" vertex="1"> <mxGeometry height="50" width="120" x="400" y="280" as="geometry" /> </mxCell> <mxCell id="6" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Admin Dashboard" vertex="1"> <mxGeometry height="50" width="120" x="400" y="440" as="geometry" /> </mxCell> <mxCell id="7" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Endpoint API Server&lt;div&gt;(on device)&lt;/div&gt;" vertex="1"> <mxGeometry height="50" width="120" x="400" as="geometry" /> </mxCell> <mxCell id="8" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Endpoint API Server&lt;div&gt;(on device)&lt;/div&gt;" vertex="1"> <mxGeometry height="50" width="120" x="560" as="geometry" /> </mxCell> <mxCell id="9" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Endpoint API Server&lt;div&gt;(on device)&lt;/div&gt;" vertex="1"> <mxGeometry height="50" width="120" x="240" as="geometry" /> </mxCell> <mxCell id="10" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="Router-1" vertex="1"> <mxGeometry height="30" width="60" x="270" y="-30" as="geometry" /> </mxCell> <mxCell id="11" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="Router-2" vertex="1"> <mxGeometry height=&quLine truncated
|
||||||
|
After Width: | Height: | Size: 971 KiB |
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user