diff --git a/README.md b/README.md
index 738b699..610eee7 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
**Операции**
- Действия по устройству — в меню «⋯» строки; действия над выбранными устройствами (бэкап, статус, обновление ROS/FW, канал, перенос в группу) появляются в полосе инструментов при выборе строк; через API их можно запускать и над всей группой.
- **Бэкап**: `.backup` (без шифрования) и `.rsc` (с `show-sensitive`, т.е. с паролями и ключами — из него можно восстановить все сущности, использующие пароль). Сервер создаёт файлы через REST, скачивает во временную папку, загружает в S3 и удаляет файлы с устройства.
-- Список бэкапов в бакете, скачивание (временная ссылка), удаление.
+- Список бэкапов в бакете, скачивание (временная ссылка), удаление одного файла или **группы выбранных** (чекбоксы, кнопка «Удалить» с подтверждением).
- Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»).
- Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API).
@@ -35,6 +35,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
## Требования к устройствам
+- Поддерживаются и RouterBOARD, и виртуальные **CHR** (у них нет `/system/routerboard`: версия FW не показывается, обновление FW пропускается).
- 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`).
@@ -78,7 +79,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
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 тестов, фоновый опрос в тестах выключен
+./venv/bin/python -m pytest # 11 тестов, фоновый опрос в тестах выключен
```
## API v1
@@ -105,6 +106,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
| 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`) |
+| POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` |
| GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач |
Внешние справочники: [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API#RESTAPI-HTTPMethods),
@@ -133,3 +135,5 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
- `010-export-show-sensitive` — `.rsc` создаётся с `show-sensitive` (секреты в файле).
- `011-dropdown-clipping` — выпадающие меню не обрезаются в узком окне.
- `012-clickable-device-name` — клик по имени устройства открывает окно изменения.
+- `013-bulk-delete-backups` — выбор файлов чекбоксами и групповое удаление бэкапов.
+- `014-chr-support` — поддержка CHR (нет `/system/routerboard`), строгое сравнение версий ROS, причина недоступности в таблице.
diff --git a/app/api/v1.py b/app/api/v1.py
index d9d1596..d62c621 100644
--- a/app/api/v1.py
+++ b/app/api/v1.py
@@ -239,6 +239,17 @@ async def download_backup(key: str):
return RedirectResponse(s3.presign_get(key))
+class KeysIn(BaseModel):
+ keys: list[str] = Field(min_length=1)
+
+
+@router.post("/backups/delete")
+async def delete_backups(body: KeysIn):
+ """Групповое удаление файлов из бакета: {"keys": [...]}."""
+ deleted, failed = await backups.delete_many(body.keys)
+ return {"deleted": deleted, "failed": failed}
+
+
@router.delete("/backups", status_code=204)
async def delete_backup(key: str):
if not s3.key_allowed(key):
diff --git a/app/ros/operations.py b/app/ros/operations.py
index cf74010..c337d8b 100644
--- a/app/ros/operations.py
+++ b/app/ros/operations.py
@@ -10,6 +10,31 @@ from app.ros.client import RosClient, RosError
CHANNELS = ("stable", "long-term", "testing", "development")
+_PRERELEASE_RE = re.compile(r"(alpha|beta|rc)", re.I)
+
+
+def _ver_key(v: str | None) -> tuple:
+ head = re.match(r"\s*([\d.]+)", v or "") # числовая часть до суффикса: '7.25rc1' -> '7.25'
+ nums = [int(x) for x in re.findall(r"\d+", head.group(1) if head else "")][:4]
+ return tuple(nums + [0] * (4 - len(nums))) + (0 if _PRERELEASE_RE.search(v or "") else 1,)
+
+
+def version_newer(latest: str | None, installed: str | None) -> bool:
+ """True, если latest действительно новее installed. Строгое сравнение: на канале long-term «последняя»
+ версия может быть старше установленной — это не обновление. '7.25rc1' < '7.25'."""
+ return bool(latest and installed) and _ver_key(latest) > _ver_key(installed)
+
+
+async def get_routerboard(c: RosClient) -> dict:
+ """system/routerboard; на CHR и x86 раздела нет (HTTP 400 no such command) — это не сбой устройства."""
+ try:
+ return await c.get("system/routerboard") or {}
+ except RosError as e:
+ if e.status == 400 and "no such command" in str(e):
+ return {"routerboard": "false"}
+ raise
+
+
async def get_status(c: RosClient, check_updates: bool = True) -> dict:
"""Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления.
@@ -18,7 +43,7 @@ async def get_status(c: RosClient, check_updates: bool = True) -> dict:
"""
resource, board, update = await asyncio.gather(
c.get("system/resource"),
- c.get("system/routerboard"),
+ get_routerboard(c),
c.get("system/package/update"),
)
check_error = None
@@ -105,8 +130,8 @@ 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})"
+ if not version_newer(latest, installed):
+ return f"Обновление не требуется (установлена {installed}, на канале {latest})"
try:
await c.post("system/package/update/install", timeout=15)
except RosError as e:
@@ -154,7 +179,7 @@ async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float =
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
поэтому по нему завершение определить нельзя."""
- board = await c.get("system/routerboard")
+ board = await get_routerboard(c)
if str(board.get("routerboard", "false")) != "true":
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
if board.get("current-firmware") == board.get("upgrade-firmware"):
diff --git a/app/services/backups.py b/app/services/backups.py
index 0bc4721..f352eaf 100644
--- a/app/services/backups.py
+++ b/app/services/backups.py
@@ -1,4 +1,5 @@
"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
+import asyncio
from datetime import date
from app import s3
@@ -47,3 +48,30 @@ async def search(device: str = "", group: str = "", kind: str = "", date_from: d
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]
+
+
+MAX_DELETE = 500
+
+
+async def delete_many(keys: list[str]) -> tuple[int, int]:
+ """Групповое удаление файлов из бакета. Все ключи проверяются до начала удаления
+ (чужой ключ — ValueError, ничего не удаляется). Возвращает (удалено, не удалось)."""
+ keys = list(dict.fromkeys(keys))
+ if not keys:
+ raise ValueError("Не выбрано ни одного файла")
+ if len(keys) > MAX_DELETE:
+ raise ValueError(f"За один раз можно удалить не более {MAX_DELETE} файлов")
+ if not all(s3.key_allowed(k) for k in keys):
+ raise ValueError("Недопустимый ключ")
+ sem = asyncio.Semaphore(8)
+
+ async def one(key: str) -> bool:
+ async with sem:
+ try:
+ await s3.delete_object(key)
+ return True
+ except Exception: # noqa: BLE001 — считаем неудачей одного файла, остальные удаляем
+ return False
+
+ results = await asyncio.gather(*(one(k) for k in keys))
+ return sum(results), len(results) - sum(results)
diff --git a/app/services/devices.py b/app/services/devices.py
index 93ffab1..1e7ccba 100644
--- a/app/services/devices.py
+++ b/app/services/devices.py
@@ -5,6 +5,7 @@ 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.operations import version_newer
from app.ros.client import RosClient
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # имя попадает в ключи S3 — только безопасные символы
@@ -129,7 +130,7 @@ def open_client(conn: Conn) -> RosClient:
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"])
+ return version_newer(st.get("ros_latest"), st.get("ros_installed"))
def has_fw_update(st: dict) -> bool:
diff --git a/app/ui/icons.py b/app/ui/icons.py
index 1fbd1c0..5840014 100644
--- a/app/ui/icons.py
+++ b/app/ui/icons.py
@@ -11,5 +11,6 @@ ICONS = {
"archive": '
| Устройство | Группа | Тип | Файл | Размер | Изменён (UTC) | Действия | |
|---|---|---|---|---|---|---|---|
| Устройство | Группа | Тип | Файл | Размер | Изменён (UTC) | Действия | |
| {{ i.device or "—" }} | {{ i.group or "—" }} | {{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }} | @@ -60,7 +73,7 @@|||||
| Файлов не найдено. | |||||||
| Файлов не найдено. | |||||||