From 0914209bf86147eb8f09feafd6aeffd2bce4262b Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sat, 19 Sep 2026 14:11:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF=D0=BE=D0=B2,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20CHR,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=B5=20=D1=81=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B9=20ROS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Групповое удаление бэкапов (docs/changes/013): - страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N / Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления; - backups.delete_many: все ключи проверяются до удаления, удаление параллельное; UI POST /backups/delete-many, API POST /api/v1/backups/delete; - выбор строк в app.js обобщён (data-select) для устройств и бэкапов. Поддержка CHR (docs/changes/014): - у CHR нет /system/routerboard (HTTP 400): устройство больше не считается недоступным, версия FW не показывается, обновление FW пропускается; - «есть обновление ROS» определяется строгим сравнением версий (на канале long-term последняя версия может быть старше установленной); - в таблице указана причина недоступности: авторизация / ошибка ответа / нет соединения. Тесты: 11 из 11. Co-Authored-By: Claude Sonnet 5 --- README.md | 8 +++- app/api/v1.py | 11 +++++ app/ros/operations.py | 33 ++++++++++++-- app/services/backups.py | 28 ++++++++++++ app/services/devices.py | 3 +- app/ui/icons.py | 1 + app/ui/routes.py | 15 ++++++- app/ui/static/app.js | 8 ++-- app/ui/static/style.css | 1 + app/ui/templates/_devices.html | 6 +-- app/ui/templates/backups.html | 21 +++++++-- docs/changes/013-bulk-delete-backups/plan.md | 8 ++++ .../013-bulk-delete-backups/summary.md | 15 +++++++ docs/changes/014-chr-support/plan.md | 5 +++ docs/changes/014-chr-support/summary.md | 6 +++ tests/test_app.py | 45 +++++++++++++++++++ 16 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 docs/changes/013-bulk-delete-backups/plan.md create mode 100644 docs/changes/013-bulk-delete-backups/summary.md create mode 100644 docs/changes/014-chr-support/plan.md create mode 100644 docs/changes/014-chr-support/summary.md 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": '', "download": '', "router": '', + "trash": '', "arrow": '', } diff --git a/app/ui/routes.py b/app/ui/routes.py index f41b287..0301b77 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates from app import s3, security from app.config import get_settings -from app.ros.operations import CHANNELS +from app.ros.operations import CHANNELS, version_newer from app.services import backups, devices, groups, jobs, ops from app.ui.icons import ICONS @@ -29,6 +29,7 @@ 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 +templates.env.globals["ros_newer"] = version_newer class LoginRequired(Exception): @@ -351,7 +352,7 @@ def _opt_date(value: str) -> date | 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 = ""): + date_from: str = "", date_to: str = "", q: str = "", deleted: int = -1, failed: int = 0): kind = kind if kind in ("backup", "rsc") else "" items, stats, error = [], {"total": 0, "size": 0}, None try: @@ -361,9 +362,19 @@ async def backups_page(request: Request, device: str = "", group: str = "", kind 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, + page_url="/backups" + ("?" + urlencode({k: v for k, v in flt.items() if v}) if any(flt.values()) else ""), + deleted=deleted, failed=failed, devices=devices.list_devices(), groups=groups.list_groups()) +@router.post("/backups/delete-many", dependencies=[Depends(require_login)]) +async def backups_delete_many(key: list[str] = Form(default=[]), next: str = Form("/backups")): + """Групповое удаление выбранных файлов; возврат на страницу с теми же фильтрами и итогом.""" + deleted, failed = await backups.delete_many(key) + back = next if next.startswith("/backups") else "/backups" + return RedirectResponse(back + ("&" if "?" in back else "?") + urlencode({"deleted": deleted, "failed": failed}), status_code=303) + + @router.get("/backups/download", dependencies=[Depends(require_login)]) async def backup_download(key: str): if not s3.key_allowed(key): diff --git a/app/ui/static/app.js b/app/ui/static/app.js index 5480d3f..6483391 100644 --- a/app/ui/static/app.js +++ b/app/ui/static/app.js @@ -5,7 +5,7 @@ // --- выбор строк: панель фильтров ⇄ панель действий над выбранными --- function syncSelection() { - const boxes = $$("input[name=device_ids]"); + const boxes = $$("input[data-select]"); const n = boxes.filter((b) => b.checked).length; boxes.forEach((b) => b.closest("tr")?.classList.toggle("sel", b.checked)); const all = $("#check-all"); @@ -54,8 +54,8 @@ 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.matches("input[data-select]")) syncSelection(); + if (t.id === "check-all") { $$("input[data-select]").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__"; @@ -66,7 +66,7 @@ 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("#clear-sel")) { $$("input[data-select]").forEach((b) => (b.checked = false)); syncSelection(); } // меню: закрывается по клику вне и после выбора пункта; открыто не более одного if (t.closest("details.sub > summary")) return; // раскрытие подменю («Сменить канал…») меню не закрывает const menu = t.closest("details.menu"); diff --git a/app/ui/static/style.css b/app/ui/static/style.css index 320473c..f741fd4 100644 --- a/app/ui/static/style.css +++ b/app/ui/static/style.css @@ -188,6 +188,7 @@ dialog.modal::backdrop { background: var(--backdrop); } .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-ok { padding: 10px 12px; border-radius: 8px; background: var(--ok-b); color: var(--ok-t); font: 500 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; } diff --git a/app/ui/templates/_devices.html b/app/ui/templates/_devices.html index 97ad421..50aea78 100644 --- a/app/ui/templates/_devices.html +++ b/app/ui/templates/_devices.html @@ -12,17 +12,17 @@ {% 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 ros_new = ros_newer(st.ros_latest, st.ros_installed) %} {% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %} - +
- {% if down %}
Нет соединения
+ {% if down %}
{% if "HTTP 401" in (d.last_error or "") %}Ошибка авторизации{% elif "HTTP " in (d.last_error or "") %}Устройство ответило ошибкой{% else %}Нет соединения{% endif %}
{% else %}
{{ d.host }}:{{ d.port }}{% if st.model %} · {{ st.model }}{% endif %}
{% endif %}
diff --git a/app/ui/templates/backups.html b/app/ui/templates/backups.html index a724c54..5b3c434 100644 --- a/app/ui/templates/backups.html +++ b/app/ui/templates/backups.html @@ -9,8 +9,13 @@ +{% if deleted >= 0 %}
+ Удалено файлов: {{ deleted }}{% if failed %}; не удалось удалить: {{ failed }}{% endif %}
{% endif %} + + +
-
+
УстройствоГруппаТипФайлРазмерИзменён (UTC)Действия {% for i in items %} + {{ i.device or "—" }} {{ i.group or "—" }} {{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }} @@ -60,7 +73,7 @@ {% else %} - Файлов не найдено. + Файлов не найдено. {% endfor %} diff --git a/docs/changes/013-bulk-delete-backups/plan.md b/docs/changes/013-bulk-delete-backups/plan.md new file mode 100644 index 0000000..0615a85 --- /dev/null +++ b/docs/changes/013-bulk-delete-backups/plan.md @@ -0,0 +1,8 @@ +# План: 013 — выбор файлов и групповое удаление бэкапов + +Запрос: комментарий владельца к листу «Резервные копии» в макете — «добавить селектор через checkbox и кнопку для операции группового удаления». +Решение (макет и приложение одинаково): +- Колонка чекбоксов и «выбрать все» в таблице файлов; при выборе полоса фильтров заменяется панелью «Выбрано: N · Удалить · Снять выбор» (тот же приём, что на странице устройств; логика выбора общая — `input[data-select]` в `app.js`). +- «Удалить» — с подтверждением и числом файлов; после удаления возврат на страницу с теми же фильтрами и итогом («Удалено файлов: N; не удалось: M»). +- Сервер: `backups.delete_many` — все ключи проверяются до начала удаления (чужой ключ или >500 файлов — ошибка, ничего не удаляется), удаление параллельное (8), сбой одного файла не останавливает остальные. UI: `POST /backups/delete-many`; API: `POST /api/v1/backups/delete` `{"keys": [...]}` → `{"deleted": N, "failed": M}`. +- Макет: лист «Резервные копии» обновлён, добавлен лист «выбраны файлы, групповое удаление». diff --git a/docs/changes/013-bulk-delete-backups/summary.md b/docs/changes/013-bulk-delete-backups/summary.md new file mode 100644 index 0000000..069c717 --- /dev/null +++ b/docs/changes/013-bulk-delete-backups/summary.md @@ -0,0 +1,15 @@ +# Итоги: 013 — групповое удаление бэкапов + +## Сделано +- Макет (версия 6): колонка чекбоксов и новый лист с панелью группового удаления. +- Приложение: выбор строк, панель «Удалить / Снять выбор», подтверждение с числом файлов, итог на странице, сохранение фильтров при возврате; иконка «корзина». +- Сервис `backups.delete_many`, маршрут `/backups/delete-many`, API `POST /api/v1/backups/delete`. +- Логика выбора строк в `app.js` обобщена (`data-select`): работает и на устройствах, и на бэкапах. + +## Проверено +- `pytest`: 10 из 10 (новый тест: чужой ключ отклоняет всю операцию и ничего не удаляет; допустимые удаляются; редирект с фильтрами и итогом; API). +- В браузере на реальном бакете с тремя собственными тестовыми файлами (`backups/qa-bulk/…`): выбор двух → панель вместо фильтров, подтверждение «(2)», итог «Удалено файлов: 2», фильтр сохранён; «выбрать все» → «Удалено файлов: 1»; ошибок JS нет. Тестовые файлы удалены, файлы `MSK_Home` и `OZ_Dacha` не тронуты. + +## Замечания +- Удаление необратимо (версионирование бакета не используется приложением) — поэтому подтверждение и ограничение префиксом `backups/`. +- Единичное удаление из меню строки «⋯» сохранено. diff --git a/docs/changes/014-chr-support/plan.md b/docs/changes/014-chr-support/plan.md new file mode 100644 index 0000000..eb3f714 --- /dev/null +++ b/docs/changes/014-chr-support/plan.md @@ -0,0 +1,5 @@ +# План: 014 — поддержка CHR и строгое сравнение версий ROS + +Проблема: два новых устройства (CHR в Yandex Cloud и OpenStack) помечались offline, хотя ping, трассировка и авторизация проходили. +Причина: на CHR нет раздела `/system/routerboard` — REST отвечает `HTTP 400 no such command or directory (routerboard)`, а `get_status` считал это сбоем устройства. +Решение: `ros/operations.py::get_routerboard` — отсутствие раздела (400 + «no such command») означает «нет RouterBOARD firmware», не ошибку; используется в статусе и обновлении FW. Второе: на канале `long-term` «последняя» версия (7.23.7) старше установленной (7.24), а сравнение «версии отличаются» показало бы это как обновление — введено строгое сравнение `version_newer` (учитывает alpha/beta/rc). Ещё: в таблице причина недоступности различается — «Ошибка авторизации» (401), «Устройство ответило ошибкой» (другие HTTP), «Нет соединения». diff --git a/docs/changes/014-chr-support/summary.md b/docs/changes/014-chr-support/summary.md new file mode 100644 index 0000000..418136d --- /dev/null +++ b/docs/changes/014-chr-support/summary.md @@ -0,0 +1,6 @@ +# Итоги: 014 — поддержка CHR + +- CHR и другие устройства без `/system/routerboard` опрашиваются нормально: модель — из `board-name`, FW — «—», обновление FW пропускается с пояснением. +- «Есть обновление ROS» определяется строго (`version_newer`): версия на канале должна быть новее установленной; `7.25rc1` < `7.25`. Затрагивает бейдж Upgrade ROS, фильтр «Есть обновление» и кнопку обновления ROS («Обновление не требуется»). +- В таблице для недоступного устройства указывается причина: авторизация / ошибка ответа / нет соединения (полный текст — в подсказке). +- Проверено: `pytest` 11 из 11 (новый тест CHR и сравнения версий); на `Yandex-Gate` и `Warsaw-Gate` статус читается (CHR, ROS 7.24, канал long-term), оба online. diff --git a/tests/test_app.py b/tests/test_app.py index bdaad20..107a150 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -229,3 +229,48 @@ async def test_poll_marks_offline_and_recovers(monkeypatch): 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" + + +def test_bulk_delete_backups(monkeypatch): + """Групповое удаление: чужой ключ отклоняет всю операцию; допустимые — удаляются (UI и API).""" + removed = [] + + async def fake_delete(key): + removed.append(key) + + monkeypatch.setattr(s3, "delete_object", fake_delete) + ok = ["backups/a/1.backup", "backups/a/1.rsc"] + with TestClient(create_app()) as c: + c.post("/login", data={"username": "admin", "password": "pw"}) + # UI: ключ вне префикса бэкапов -> ошибка, ничего не удалено + r = c.post("/backups/delete-many", data={"key": ok + ["other/secret.txt"]}, follow_redirects=False) + assert r.status_code == 400 and removed == [] + # UI: допустимые ключи -> возврат на страницу с итогом + r = c.post("/backups/delete-many", data={"key": ok, "next": "/backups?group=1"}, follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/backups?group=1&deleted=2&failed=0" + assert sorted(removed) == ok + # API + removed.clear() + h = {"Authorization": "Bearer test-token"} + assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ok}).json() == {"deleted": 2, "failed": 0} + assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ["x/../y"]}).status_code == 400 + + +@pytest.mark.asyncio +async def test_chr_status_and_version_compare(): + """CHR: раздела system/routerboard нет (HTTP 400) — это не сбой; «последняя» версия старше установленной — не обновление.""" + def handler(r: httpx.Request) -> httpx.Response: + if r.url.path == "/rest/system/routerboard": + return httpx.Response(400, json={"detail": "no such command or directory (routerboard)", "error": 400}) + data = { + "/rest/system/resource": {"board-name": "CHR Yandex epyc-9654", "version": "7.24 (stable)", "uptime": "1d"}, + "/rest/system/package/update": {"channel": "long-term", "installed-version": "7.24", "latest-version": "7.23.7"}, + } + return httpx.Response(200, json=data.get(r.url.path, [])) + + async with ros_client(handler) as c: + st = await ros.get_status(c) + assert await ros.upgrade_firmware(c) == "Устройство без RouterBOARD firmware (например, CHR) — пропущено" + assert st["model"].startswith("CHR") and st["fw_current"] is None + assert not ros.version_newer(st["ros_latest"], st["ros_installed"]) # 7.23.7 старше 7.24 + assert ros.version_newer("7.24.1", "7.24") and ros.version_newer("7.25", "7.25rc1") and not ros.version_newer("7.25rc1", "7.25")