Состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала
После смены канала на long-term колонка показывала «актуально», хотя
версия канала (7.23.7) старше установленной (7.24.4) — docs/changes/023:
- состояния колонки: «↑ X», «актуально», «канал: X» (версия канала старше
установленной), «проверка не удалась» (ошибка check-for-updates больше
не маскируется под «актуально»), «—»;
- откат ROS до версии канала как отдельная задача ros_downgrade: проверка
версии на устройстве → обязательный бэкап (сбой прерывает) → install;
- UI: «Откатить ROS…» в меню устройства и групповой пункт в «Обновление»,
окно с вводом целевой версии, список не затрагиваемых устройств;
- API: POST /devices/{id}/update/downgrade и /batch/ros_downgrade с
обязательным target_version. Штатное обновление откат не выполняет.
Тесты: 33 из 33. Стенд: 422 без версии, задачи отката на недоступном
устройстве завершаются на проверке без бэкапа. Реальный откат и ручная
проверка UI пользователем на момент коммита не выполнены.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bda0038d0
commit
ae826fc562
14 files changed
+437
-13
No files matched your search
@@ -15,6 +15,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
|
||||
- Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения; **само имя задаётся только при создании и не меняется** (оно входит в ключи бэкапов).
|
||||
- К устройству можно добавить **примечание** (до 500 символов): видно подсказкой при наведении на имя.
|
||||
- Статус: online/offline, модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа. Колонки **Upgrade ROS** и **Upgrade FW** показывают версию, до которой можно обновиться (устройство проверяет обновления на серверах MikroTik; без интернета на устройстве — «—»).
|
||||
- Колонка **Upgrade ROS** — пять состояний: обновление доступно («↑ версия»), актуально, **«канал: версия»** — версия канала старше установленной (например, после переключения на `long-term`: это не обновление, а осознанный откат), «проверка не удалась» — последняя проверка обновлений завершилась ошибкой, «—» — проверка ещё не выполнялась.
|
||||
- **Мониторинг доступности**: сервер опрашивает устройства каждые 30 с (`POLL_INTERVAL`), недоступность определяется за ~4 с на устройство (`ROS_CONNECT_TIMEOUT`); страница обновляет статусы сама, без перезагрузки.
|
||||
|
||||
**Группы и фильтры**
|
||||
@@ -26,6 +27,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
|
||||
- **Бэкап**: `.backup` (без шифрования) и `.rsc` (с `show-sensitive`, т.е. с паролями и ключами — из него можно восстановить все сущности, использующие пароль). Сервер создаёт файлы через REST, скачивает во временную папку, загружает в S3 и удаляет файлы с устройства.
|
||||
- Список бэкапов в бакете, скачивание (временная ссылка), удаление одного файла или **группы выбранных** (чекбоксы, кнопка «Удалить» с подтверждением).
|
||||
- Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»).
|
||||
- **Откат ROS до версии канала** (состояние «канал: X»): окно с вводом целевой версии (кнопка активна только после ввода, сервер сверяет версию с кэшем статуса) — для одного устройства (пункт «Откатить ROS…» в меню «⋯», виден только в состоянии отката) и для группы (пункт «Откатить ROS до версии канала…» в меню «Обновление» с выбранными устройствами; устройства, которые откатывать нельзя, показаны отдельным списком «Не будут затронуты»). Перед откатом — обязательный бэкап: не удался — откат не запускается. Реальный откат на устройстве запускает пользователь в UI.
|
||||
- Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API).
|
||||
|
||||
## Идентификаторы и журнал событий
|
||||
@@ -40,7 +42,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
|
||||
## Интерфейс
|
||||
|
||||
Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными.
|
||||
Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного `<select>`, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются.
|
||||
Добавление и изменение устройств, создание и переименование групп, откат ROS — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного `<select>`, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются.
|
||||
|
||||
## Требования к устройствам
|
||||
|
||||
@@ -98,7 +100,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 # 29 тестов, фоновый опрос в тестах выключен
|
||||
./venv/bin/python -m pytest # 33 теста, фоновый опрос в тестах выключен
|
||||
```
|
||||
|
||||
## API v1
|
||||
@@ -119,11 +121,13 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
||||
| 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}/update/downgrade` | `{"target_version": "7.23.7"}` (обязательно) — откат ROS до версии канала: бэкап, затем откат (задача `ros_downgrade`); версия сверяется на устройстве, не совпала — задача завершается ошибкой |
|
||||
| POST | `/api/v1/devices/{id}/firmware/upgrade` | обновление FW (задача) |
|
||||
| GET/POST | `/api/v1/groups` | группы (с числом устройств) / создать |
|
||||
| PATCH/DELETE | `/api/v1/groups/{id}` | переименовать / удалить (устройства остаются без группы) |
|
||||
| POST | `/api/v1/batch/{backup\|ros_update\|fw_update}` | `{"device_ids": [...]}` и/или `{"group_id": N}` — групповая операция (задача) |
|
||||
| PUT | `/api/v1/batch/channel` | `{"device_ids": [...] или "group_id": N, "channel": "..."}` — групповая смена канала (задача `set_channel`) → 202 `{"job_ids": [...]}` |
|
||||
| POST | `/api/v1/batch/ros_downgrade` | `{"device_ids": [...] или "group_id": N, "target_version": "7.23.7"}` — групповой откат ROS (задача `ros_downgrade`); отдельный эндпоинт — `ros_downgrade` в `Literal` `/batch/{action}` не входит, версия там обязательна |
|
||||
| GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш) |
|
||||
| POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` |
|
||||
| GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач |
|
||||
@@ -167,3 +171,4 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
||||
- `020-custom-select-menus` — выпадающие списки (фильтры, «Группа») в стиле меню действий «⋯»: прогрессивное улучшение в JS, нативный `select` остаётся в разметке и работает без JavaScript.
|
||||
- `021-security-hardening` — отказ старта при небезопасных секретах (`API_TOKEN`/`SESSION_SECRET`/`ADMIN_PASSWORD`/`SECRET_KEY`), блокировка входа в UI по IP клиента, `SESSION_COOKIE_SECURE`, безопасный `next` в редиректах (`/ui/move`, `/backups/delete-many`).
|
||||
- `022-async-db-remainder` — остаток п. 9 ревью: оставшиеся синхронные обращения к БД в async-обработчиках (API, UI, `ops`, `backups.search`) — через `asyncio.to_thread`; `jobs.start_jobs` разделён на синхронную `_create_jobs` (в потоке) и `async start_jobs` (создаёт задачи и планирует их в event loop); регрессионный тест-линтер (AST-обход `app/`) не даёт синхронным обращениям к БД вернуться в async-код.
|
||||
- `023-ros-downgrade` — пять состояний колонки «Upgrade ROS» (`update`/`current`/`downgrade`/`check_error`/`unknown`; строгое сравнение версий больше не путает откат канала с «актуально»); осознанный откат ROS до версии канала с обязательным бэкапом — для одного устройства и группы, в UI (окно с подтверждением версии) и API (`/update/downgrade`, `/batch/ros_downgrade`).
|
||||
@@ -108,6 +108,14 @@ class BatchChannelIn(BatchIn):
|
||||
channel: Literal[CHANNELS] # type: ignore[valid-type]
|
||||
|
||||
|
||||
class DowngradeIn(BaseModel):
|
||||
target_version: str = Field(min_length=1)
|
||||
|
||||
|
||||
class BatchDowngradeIn(BatchIn):
|
||||
target_version: str = Field(min_length=1)
|
||||
|
||||
|
||||
# --- устройства ---
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceOut])
|
||||
@@ -203,6 +211,21 @@ async def upgrade_firmware(device_id: str):
|
||||
return {"job_ids": await jobs.start_jobs("fw_update", [device_id])}
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/update/downgrade", status_code=202)
|
||||
async def downgrade_update(device_id: str, body: DowngradeIn):
|
||||
"""Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на
|
||||
устройстве в задаче — если она не совпала с подтверждённой, задача завершается ошибкой."""
|
||||
return {"job_ids": await jobs.start_jobs("ros_downgrade", [device_id], {"target_version": body.target_version})}
|
||||
|
||||
|
||||
@router.post("/batch/ros_downgrade", status_code=202)
|
||||
async def batch_ros_downgrade(body: BatchDowngradeIn):
|
||||
"""Групповой откат ROS — отдельный эндпоинт с обязательной target_version (не входит в /batch/{action});
|
||||
регистрируется раньше /batch/{action}, иначе Starlette отдаст этот путь туда (action="ros_downgrade" не в Literal)."""
|
||||
device_ids = await asyncio.to_thread(body.resolve)
|
||||
return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})}
|
||||
|
||||
|
||||
@router.post("/batch/{action}", status_code=202)
|
||||
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
|
||||
"""Групповая операция над списком устройств."""
|
||||
|
||||
+36
-6
@@ -125,22 +125,52 @@ async def set_channel(c: RosClient, channel: str) -> None:
|
||||
await c.post("system/package/update/set", {"channel": channel})
|
||||
|
||||
|
||||
async def install_ros_update(c: RosClient) -> str:
|
||||
"""Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания."""
|
||||
async def _check_update(c: RosClient) -> tuple[str | None, str | None]:
|
||||
"""check-for-updates + чтение system/package/update. Возвращает (installed, latest)."""
|
||||
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 version_newer(latest, installed):
|
||||
return f"Обновление не требуется (установлена {installed}, на канале {latest})"
|
||||
return u.get("installed-version"), u.get("latest-version")
|
||||
|
||||
|
||||
async def _install(c: RosClient) -> None:
|
||||
"""POST system/package/update/install: после скачивания устройство уходит в перезагрузку
|
||||
и рвёт соединение — это штатно (RosError со status=None)."""
|
||||
try:
|
||||
await c.post("system/package/update/install", timeout=15)
|
||||
except RosError as e:
|
||||
# после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно
|
||||
if e.status is not None:
|
||||
raise
|
||||
|
||||
|
||||
async def install_ros_update(c: RosClient) -> str:
|
||||
"""Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания."""
|
||||
installed, latest = await _check_update(c)
|
||||
if not version_newer(latest, installed):
|
||||
return f"Обновление не требуется (установлена {installed}, на канале {latest})"
|
||||
await _install(c)
|
||||
return f"Обновление {installed} → {latest} запущено, устройство перезагрузится"
|
||||
|
||||
|
||||
async def check_downgrade(c: RosClient, target_version: str) -> tuple[str | None, str | None]:
|
||||
"""Проверяет, что версия канала совпадает с подтверждённой пользователем и действительно старше
|
||||
установленной (иначе это не откат). Install не выполняет — чтобы не делать бэкап устройства,
|
||||
которое откатывать нельзя. Возвращает (installed, latest)."""
|
||||
installed, latest = await _check_update(c)
|
||||
if latest != target_version:
|
||||
raise RosError(f"Версия канала {latest} не совпадает с подтверждённой {target_version} — откат отменён")
|
||||
if not version_newer(installed, latest):
|
||||
raise RosError(f"Версия канала {latest} не старше установленной {installed} — это не откат")
|
||||
return installed, latest
|
||||
|
||||
|
||||
async def downgrade_ros(c: RosClient, target_version: str) -> str:
|
||||
"""Осознанный откат ROS до версии канала (long-term/testing старше установленной): RouterOS 7 при
|
||||
/system/package/update/install скачивает версию канала и выполняет откат, если она старше установленной."""
|
||||
installed, latest = await check_downgrade(c, target_version)
|
||||
await _install(c)
|
||||
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)?$")
|
||||
|
||||
|
||||
@@ -155,6 +155,21 @@ def has_ros_update(st: dict) -> bool:
|
||||
return version_newer(st.get("ros_latest"), st.get("ros_installed"))
|
||||
|
||||
|
||||
def ros_state(st: dict) -> str:
|
||||
"""Состояние колонки «Upgrade ROS»: unknown — нет данных проверки; check_error — последняя проверка
|
||||
не удалась (старая latest-version могла сохраниться); update — есть обновление; downgrade — версия
|
||||
канала старше установленной (long-term/testing после переключения канала); иначе current."""
|
||||
if not (st.get("ros_latest") and st.get("ros_installed")):
|
||||
return "unknown"
|
||||
if st.get("ros_check_error"):
|
||||
return "check_error"
|
||||
if version_newer(st["ros_latest"], st["ros_installed"]):
|
||||
return "update"
|
||||
if version_newer(st["ros_installed"], st["ros_latest"]):
|
||||
return "downgrade"
|
||||
return "current"
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ JOB_TYPES = {
|
||||
"ros_update": ops.run_ros_update,
|
||||
"fw_update": ops.run_fw_update,
|
||||
"set_channel": ops.run_set_channel,
|
||||
"ros_downgrade": ops.run_ros_downgrade,
|
||||
}
|
||||
|
||||
_tasks: set[asyncio.Task] = set()
|
||||
|
||||
@@ -154,3 +154,16 @@ async def run_ros_update(device_id: str) -> str:
|
||||
async def run_fw_update(device_id: str) -> str:
|
||||
async with devices.open_client(await _conn(device_id)) as c:
|
||||
return await ros.upgrade_firmware(c)
|
||||
|
||||
|
||||
async def run_ros_downgrade(device_id: str, target_version: str) -> str:
|
||||
"""Осознанный откат ROS до версии канала: 1) проверка, что версия канала совпадает с подтверждённой
|
||||
и действительно старше установленной; 2) обязательный бэкап — сбой прерывает задачу, откат не
|
||||
запускается (бэкап привязывается к этой же задаче через job_id, ContextVar events.current_job);
|
||||
3) повторная проверка и запуск отката на новом подключении."""
|
||||
async with devices.open_client(await _conn(device_id)) as c:
|
||||
await ros.check_downgrade(c, target_version)
|
||||
backup_msg = await run_backup(device_id)
|
||||
async with devices.open_client(await _conn(device_id)) as c:
|
||||
install_msg = await ros.downgrade_ros(c, target_version)
|
||||
return f"{install_msg}. {backup_msg}"
|
||||
+49
-1
@@ -5,7 +5,7 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, Response
|
||||
from fastapi import APIRouter, Depends, Form, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -47,6 +47,7 @@ templates.env.globals["ev_kind"] = lambda t: _EV_KIND_BY_TYPE.get(t, "plain")
|
||||
templates.env.globals["ev_entity"] = lambda t: _EV_ENTITY.get(t, t)
|
||||
templates.env.globals["ICONS"] = ICONS
|
||||
templates.env.globals["ros_newer"] = version_newer
|
||||
templates.env.globals["ros_state"] = devices.ros_state
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
@@ -234,6 +235,25 @@ async def device_action(request: Request, device_id: str, action: str, channel:
|
||||
return _render(request, "_devices.html", oob=True, **await asyncio.to_thread(_devices_ctx, flt))
|
||||
|
||||
|
||||
@router.post("/ui/downgrade", dependencies=[Depends(require_login)])
|
||||
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default="")):
|
||||
"""Откат ROS для выбранных устройств (окно «Откатить ROS…»): из выбранных берутся только те, у кого
|
||||
в кэше статуса ros_state == downgrade и версия канала совпадает с введённой; иначе окно остаётся
|
||||
открытым с ошибкой — задачи не создаются."""
|
||||
target_version = target_version.strip()
|
||||
wanted = set(device_ids)
|
||||
|
||||
def _load():
|
||||
return [d for d in devices.list_devices() if d.id in wanted]
|
||||
|
||||
devs = await asyncio.to_thread(_load)
|
||||
matched = [d for d in devs if devices.ros_state(d.status) == "downgrade" and d.status.get("ros_latest") == target_version]
|
||||
if not matched:
|
||||
return _downgrade_dialog(request, devs, "Версия не совпадает с версией канала выбранных устройств")
|
||||
await jobs.start_jobs("ros_downgrade", [d.id for d in matched], {"target_version": target_version})
|
||||
return _done(request)
|
||||
|
||||
|
||||
def _safe_next(value: str, default: str = "/", prefix: str = "/") -> str:
|
||||
"""Адрес для редиректа после формы, присланный пользователем (`next`/`back`): только локальный путь,
|
||||
иначе открытый редирект (в т.ч. `\\evil.com`, который браузеры трактуют как `//evil.com`)."""
|
||||
@@ -303,6 +323,34 @@ def dialog_group_edit(request: Request, group_id: str):
|
||||
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
|
||||
|
||||
|
||||
# --- откат ROS до версии канала ---
|
||||
|
||||
def _downgrade_split(devs: list) -> tuple[list, list]:
|
||||
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
||||
affected, skipped = [], []
|
||||
for d in devs:
|
||||
rst = devices.ros_state(d.status)
|
||||
if rst == "downgrade":
|
||||
affected.append(d)
|
||||
else:
|
||||
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
|
||||
skipped.append((d, reason))
|
||||
return affected, skipped
|
||||
|
||||
|
||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
|
||||
affected, skipped = _downgrade_split(devs)
|
||||
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
|
||||
skipped=skipped, error=error)
|
||||
|
||||
|
||||
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
||||
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])):
|
||||
wanted = set(device_ids)
|
||||
devs = [d for d in devices.list_devices() if d.id in wanted]
|
||||
return _downgrade_dialog(request, devs)
|
||||
|
||||
|
||||
# --- группы ---
|
||||
|
||||
@router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set st = d.status %}{% set down = d.online is false %}
|
||||
{% set ros_new = ros_newer(st.ros_latest, st.ros_installed) %}
|
||||
{% set rst = ros_state(st) %}
|
||||
{% 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" data-select aria-label="Выбрать {{ d.name }}"></td>
|
||||
@@ -31,8 +31,10 @@
|
||||
<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>
|
||||
{% if down or rst == "unknown" %}<span class="dash" title="{{ st.ros_check_error or 'Проверка обновлений ещё не выполнялась' }}">—</span>
|
||||
{% elif rst == "check_error" %}<span class="badge warn" title="{{ st.ros_check_error }}">проверка не удалась</span>
|
||||
{% elif rst == "update" %}<span class="badge warn" title="Доступно обновление ROS до {{ st.ros_latest }}">{{ ui.icon("up", 12, 2.2) }}{{ st.ros_latest }}</span>
|
||||
{% elif rst == "downgrade" %}<span class="badge" title="Версия канала {{ st.ros_latest }} старше установленной {{ st.ros_installed }}. Обновление не требуется; откат — пункт «Откатить ROS…»">канал: {{ 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>
|
||||
@@ -52,6 +54,9 @@
|
||||
<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>
|
||||
{% if rst == "downgrade" %}
|
||||
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?device_ids={{ d.id }}" hx-target="#modal-body">Откатить ROS…<small>Версия канала {{ st.ros_latest }} старше установленной {{ st.ros_installed }}</small></button>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
|
||||
{% import "_ui.html" as ui %}
|
||||
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
|
||||
<header class="modal-head">
|
||||
<h2 id="modal-title">Откатить ROS до версии канала</h2>
|
||||
<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
|
||||
{% for d in devs %}<input type="hidden" name="device_ids" value="{{ d.id }}">{% endfor %}
|
||||
{% if affected %}
|
||||
<table class="grid" style="width:100%">
|
||||
<thead><tr><th>Устройство</th><th>Установлено → канал</th><th>Канал</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in affected %}
|
||||
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status.ros_installed }} → {{ d.status.ros_latest }}</td><td>{{ d.status.channel }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<div class="form-err" role="alert">Среди выбранных устройств нет ни одного в состоянии отката.</div>{% endif %}
|
||||
{% if skipped %}
|
||||
<div class="note">Не будут затронуты:
|
||||
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="note">Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки
|
||||
устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. Если версия
|
||||
канала у выбранных устройств различается, откатятся только те, чья версия канала совпадает с введённой ниже —
|
||||
остальные останутся без изменений.</div>
|
||||
<label class="lbl"><span>Введите целевую версию <small>должна совпадать с версией канала выбранных устройств</small></span>
|
||||
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
|
||||
</div>
|
||||
<footer class="modal-foot">
|
||||
<span class="grow"></span>
|
||||
<button type="button" class="btn ghost-n" data-close>Отмена</button>
|
||||
<button class="btn danger" id="downgrade-confirm" disabled>Откатить</button>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -18,7 +18,7 @@
|
||||
<tr>
|
||||
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
|
||||
<td style="font-weight:500">{{ j.device_name }}</td>
|
||||
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала"}.get(j.type, j.type) }}</td>
|
||||
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS"}.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>
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<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 bad" hx-get="/ui/dialog/downgrade" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить 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>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# План: 023 — состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала
|
||||
|
||||
## Context
|
||||
|
||||
Пользователь переключил канал обновлений на `long-term` (и `testing`), а колонка «Upgrade ROS» продолжала показывать «актуально».
|
||||
Разбор (оркестратор):
|
||||
- Цепочка обновления статуса корректна: `ops.set_channel` → `refresh_status` → `check-for-updates` (в REST синхронный — ответ приходит
|
||||
с итоговой `latest-version`, проверено на реальном устройстве) → статус сохраняется.
|
||||
- Версии каналов MikroTik сейчас: stable 7.24.4, **testing 7.24.4**, **long-term 7.23.7**, development 7.25beta5; у устройств установлена 7.24.4.
|
||||
На `testing` «актуально» верно. На `long-term` версия канала **старше** установленной: RouterOS пишет `New version is available`
|
||||
(установка = откат), а приложение по строгому сравнению (`ros.version_newer`, коммит `0914209`) показывает «актуально» — вводит в заблуждение.
|
||||
- Попутный дефект: если последний `check-for-updates` завершился ошибкой (`ros_check_error`), а старая `latest-version` сохранилась,
|
||||
таблица тоже показывает «актуально»; ошибка видна только при пустой `latest-version`.
|
||||
|
||||
Требование пользователя: переход stable → long-term должен быть возможен как **осознанная опция отката (downgrade)**.
|
||||
|
||||
Решения пользователя:
|
||||
- Подтверждение — **окно с вводом целевой версии**: кнопка активна только после ввода версии; сервер проверяет, что она совпадает с текущей версией канала.
|
||||
- **Обязательный бэкап** перед откатом; бэкап не удался — откат не выполняется.
|
||||
- Откат для **одного устройства и группой** (UI и API).
|
||||
- Реальный откат на устройстве запускает **пользователь** в UI; оркестратор проверяет всё без реального отката (тесты с имитацией RouterOS).
|
||||
|
||||
Техническая основа: в RouterOS 7 `/system/package/update/install` при версии канала старше установленной скачивает версию канала и выполняет откат
|
||||
(отсюда `New version is available`). Штатное «Upgrade ROS» (`ros.install_ros_update`) откат по-прежнему **не** выполняет.
|
||||
|
||||
## Изменения
|
||||
|
||||
### Отображение (`app/ui/templates/_devices.html`, `app/ros/operations.py` или `app/services/devices.py`)
|
||||
- Хелпер состояния (рядом с `devices.has_ros_update`): `ros_state(st) -> "unknown" | "check_error" | "update" | "current" | "downgrade"`:
|
||||
`unknown` — нет `ros_latest`/`ros_installed`; `check_error` — есть `ros_check_error`; `update` — `version_newer(latest, installed)`;
|
||||
`downgrade` — `version_newer(installed, latest)`; иначе `current`. Экспортировать в шаблоны (как `ros_newer`).
|
||||
- Колонка «Upgrade ROS»: `update` — как сейчас («↑ X», warn); `current` — «актуально»; **`downgrade`** — нейтральная метка «канал: X»
|
||||
с подсказкой «Версия канала X старше установленной Y. Обновление не требуется; откат — пункт «Откатить ROS…»»;
|
||||
**`check_error`** — метка-предупреждение «проверка не удалась», текст ошибки в подсказке; `unknown` — «—» как сейчас.
|
||||
- Стили меток — существующие классы `badge` и токены; новых цветов нет.
|
||||
- Фильтр «Обновления» не меняется (`downgrade` не считается обновлением).
|
||||
|
||||
### Операция отката (`app/ros/operations.py`, `app/services/ops.py`, `app/services/jobs.py`)
|
||||
- `ros.downgrade_ros(c, target_version) -> str`: `check-for-updates` (timeout как в `install_ros_update`), чтение `system/package/update`;
|
||||
если `latest-version != target_version` → `RosError("Версия канала X не совпадает с подтверждённой Y — откат отменён")`;
|
||||
если не `version_newer(installed, latest)` → `RosError("Версия канала X не старше установленной Y — это не откат")`;
|
||||
иначе `POST system/package/update/install` (обрыв соединения при перезагрузке — штатно, как в `install_ros_update`);
|
||||
сообщение «Откат Y → X запущен, устройство перезагрузится». **Переиспользовать** разбор ответа и обработку обрыва из `install_ros_update`
|
||||
(вынести общую часть, не копировать).
|
||||
- Проверка до бэкапа: вынести первую половину (check + сверка версий) в `ros.check_downgrade(c, target) -> (installed, latest)`,
|
||||
чтобы не делать бэкап устройства, которое откатывать нельзя.
|
||||
- `ops.run_ros_downgrade(device_id, target_version) -> str`: 1) подключение, `check_downgrade`; 2) **`await run_backup(device_id)`** — ошибка
|
||||
прерывает задачу, откат не запускается (бэкап привязывается к той же задаче через ContextVar `job_id`); 3) новое подключение,
|
||||
повторная `check_downgrade` + `install` (`downgrade_ros`). Итоговое сообщение включает ключи бэкапа.
|
||||
- `jobs.JOB_TYPES["ros_downgrade"] = ops.run_ros_downgrade`; параметр `target_version` — через `params` (как `channel` у `set_channel`).
|
||||
Подпись типа в `_jobs.html`: «Откат ROS».
|
||||
|
||||
### API (`app/api/v1.py`)
|
||||
- `POST /api/v1/devices/{id}/update/downgrade` — тело `{"target_version": "7.23.7"}` (обязательное, непустое) → 202 `{"job_ids"}`.
|
||||
- `POST /api/v1/batch/ros_downgrade` — `BatchIn` + `target_version` → 202 `{"job_ids"}`. Существующий `/batch/{action}` не меняется
|
||||
(`ros_downgrade` в его `Literal` **не** добавлять — откат только через эндпоинт с обязательной версией).
|
||||
- Предварительная проверка по кэшу статуса не делается в API (достоверна только проверка на устройстве в задаче); задача на устройстве
|
||||
с несовпадающей версией завершается `failed` с понятным сообщением.
|
||||
|
||||
### UI (`app/ui/routes.py`, шаблоны, `app/ui/static/app.js` при необходимости)
|
||||
- Окно `GET /ui/dialog/downgrade?device_ids=…` (для одного устройства — из меню «⋯» строки, пункт «Откатить ROS…», виден только при
|
||||
состоянии `downgrade`; для группы — пункт «Откатить ROS до версии канала…» в меню «Обновление» с выбранными устройствами):
|
||||
- таблица выбранных устройств: имя, установлено → версия канала (из кэша статуса), канал;
|
||||
- устройства не в состоянии `downgrade` — отдельным списком «Не будут затронуты» (причина: «версия канала не старше установленной» / «нет данных проверки»);
|
||||
- если у устройств разные версии канала — откатываются только совпадающие с введённой, остальные показаны как «не будут затронуты»;
|
||||
- предупреждение: перед откатом создаётся бэкап; устройство перезагрузится; конфигурация новой версии может быть частично несовместима;
|
||||
- поле «Введите целевую версию» (`input[data-enables]` — существующий механизм `syncEnables`), кнопка «Откатить» (класс опасного действия, как «Удалить») неактивна, пока поле пусто.
|
||||
- `POST /ui/downgrade`: `device_ids`, `target_version`; сервер оставляет устройства, у которых в кэше `ros_state == "downgrade"` и
|
||||
`ros_latest == target_version`; если введённая версия не совпала ни с одним — окно остаётся открытым с ошибкой «Версия не совпадает
|
||||
с версией канала выбранных устройств»; иначе задачи `ros_downgrade` и обновление панели «Задачи» (как у групповых действий; окно закрывается — `HX-Refresh` или закрытие окна и обновление `#jobs`).
|
||||
- Разметка окна — по образцу существующих окон (`_events_clear.html` — окно подтверждения опасного действия, `_device_form.html`).
|
||||
|
||||
## Тесты (минимально, `tests/test_app.py`, RouterOS — `httpx.MockTransport`, как в существующих тестах)
|
||||
- `ros_state`: update / current / downgrade / check_error / unknown.
|
||||
- `run_ros_downgrade`: версия не совпала → задача `failed`, **бэкап и install не вызывались**; совпала → бэкап вызван **до** install (порядок),
|
||||
install вызван; бэкап упал → install не вызывался.
|
||||
- API: `target_version` обязателен (422 без него); `/batch/ros_downgrade` → 202 и тип задачи `ros_downgrade`; `/batch/ros_downgrade` через `/batch/{action}` недоступен (422).
|
||||
- UI: `POST /ui/downgrade` с несовпадающей версией → ошибка в окне, задач нет.
|
||||
|
||||
## Документация
|
||||
README: «Возможности»/«Интерфейс» (состояния колонки, откат), таблица API (два эндпоинта), типы задач, число тестов, строка 023 в истории изменений.
|
||||
`summary.md` — оркестратор.
|
||||
|
||||
## Исполнение
|
||||
Исполнитель (Sonnet): код, тесты, README, пересборка стенда. Тесты не запускает, не коммитит, `.env` не читает.
|
||||
**Реальный откат на устройствах не запускать** (ни исполнителю, ни оркестратору): его выполняет пользователь в UI.
|
||||
|
||||
## Проверка
|
||||
- `pytest` — все зелёные.
|
||||
- Стенд (override 8001, `--force-recreate`): новый код; колонка на реальных данных — устройства на `stable` с 7.24.4 «актуально»
|
||||
(при переключении пользователем на long-term — «канал: 7.23.7» и пункт «Откатить ROS…»).
|
||||
- Безопасные проверки на стенде: `POST /api/v1/devices/{id}/update/downgrade` для временного устройства `192.0.2.1` → 202, задача `failed`
|
||||
на проверке (устройство недоступно), бэкап не создан; `target_version` отсутствует → 422. Временное устройство удаляется.
|
||||
- Ручная проверка — пользователь: отображение состояний; окно отката (неактивная кнопка, неверная версия, список «не будут затронуты»);
|
||||
реальный откат на выбранном устройстве — с бэкапом в S3 до перезагрузки.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Итоги: 023 — состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала
|
||||
|
||||
## Разбор исходной жалобы
|
||||
После смены канала колонка показывала «актуально». Цепочка обновления статуса корректна (`check-for-updates` в REST синхронный — проверено
|
||||
на реальном устройстве). Причина — отображение: версии каналов MikroTik — stable 7.24.4, testing 7.24.4 (совпадает — «актуально» верно),
|
||||
long-term 7.23.7 (старше установленной 7.24.4 — RouterOS пишет `New version is available`, а приложение по строгому сравнению показывало «актуально»).
|
||||
Попутно: при ошибке `check-for-updates` и сохранённой старой `latest-version` тоже показывалось «актуально».
|
||||
|
||||
## Сделано
|
||||
- `devices.ros_state(st)`: `unknown` / `check_error` / `update` / `downgrade` / `current`. Колонка «Upgrade ROS»: «↑ X», «актуально»,
|
||||
нейтральная «канал: X» (версия канала старше установленной), предупреждение «проверка не удалась» (ошибка в подсказке), «—».
|
||||
Фильтр «Обновления» и штатное обновление не изменились.
|
||||
- `ros.check_downgrade` (версия канала = подтверждённой и старше установленной), `ros.downgrade_ros`; общая часть с `install_ros_update`
|
||||
вынесена (`_check_update`, `_install` — обрыв соединения при перезагрузке штатен).
|
||||
- Задача `ros_downgrade` (`ops.run_ros_downgrade`, параметр `target_version`): проверка → **обязательный бэкап** (сбой прерывает) →
|
||||
повторная проверка и install; сообщение содержит ключи бэкапа, бэкап привязан к той же задаче.
|
||||
- API: `POST /api/v1/devices/{id}/update/downgrade`, `POST /api/v1/batch/ros_downgrade` (обязательный `target_version`, 202);
|
||||
маршрут зарегистрирован до `/batch/{action}`, в общий `Literal` откат не добавлен.
|
||||
- UI: пункт «Откатить ROS…» в меню «⋯» (только в состоянии `downgrade`), «Откатить ROS до версии канала…» в меню «Обновление»;
|
||||
окно `_dialog_downgrade.html`: что будет откатано, «Не будут затронуты» с причинами, предупреждения, ввод целевой версии
|
||||
(кнопка неактивна до ввода), при несовпадении — ошибка в окне без задач. Подпись задачи «Откат ROS».
|
||||
|
||||
## Проверено
|
||||
- `pytest`: 33 из 33 (новые: состояния, порядок «бэкап → install» и отсутствие install при несовпадении/сбое бэкапа, API, окно UI).
|
||||
- Стенд (8001), временное недоступное устройство: без `target_version` → 422; оба эндпоинта → 202; задачи `ros_downgrade` → `failed`
|
||||
на проверке, **бэкап не создавался**. Временное устройство удалено.
|
||||
- Реальные устройства (все на stable 7.24.4): «актуально», пунктов отката нет — верно.
|
||||
- Боевые данные: группы, устройства, бэкапы совпадают по ID; добавлено 3 задачи и 12 событий проверки
|
||||
(одна — лишний `ros_update` на временном недоступном устройстве, ошибочно запущенный оркестратором; на данные не повлиял).
|
||||
|
||||
## Оговорки
|
||||
- **Реальный откат не выполнялся**: поведение RouterOS 7 `update/install` при версии канала старше установленной (скачать и откатить)
|
||||
подтверждено статусом устройства (`New version is available`), но не прогоном — проверяет пользователь.
|
||||
- Прошивка RouterBOARD после отката ROS остаётся новее — отдельно не обрабатывается.
|
||||
- Ручная проверка UI пользователем на момент коммита не подтверждена.
|
||||
@@ -455,6 +455,121 @@ async def test_batch_channel_runs_as_jobs(monkeypatch):
|
||||
assert c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id], "channel": "bogus"}).status_code == 422
|
||||
|
||||
|
||||
def test_ros_state_values():
|
||||
"""Состояния колонки «Upgrade ROS»: unknown / check_error / update / downgrade / current."""
|
||||
assert devices.ros_state({}) == "unknown"
|
||||
assert devices.ros_state({"ros_installed": "7.24.4"}) == "unknown" # нет latest — проверка ещё не выполнялась
|
||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.16", "ros_check_error": "timeout"}) == "check_error"
|
||||
assert devices.ros_state({"ros_installed": "7.15", "ros_latest": "7.16"}) == "update"
|
||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.23.7"}) == "downgrade" # long-term старше установленной
|
||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_ros_downgrade_order_and_failures(monkeypatch):
|
||||
"""run_ros_downgrade: версия не совпала с подтверждённой — бэкап и install не вызываются; версия совпала —
|
||||
бэкап выполняется до install (порядок); бэкап упал — install не вызывается."""
|
||||
calls = []
|
||||
|
||||
def handler(r: httpx.Request) -> httpx.Response:
|
||||
if r.url.path == "/rest/system/package/update/install":
|
||||
calls.append("install")
|
||||
return httpx.Response(200, json=[])
|
||||
data = {"/rest/system/package/update": {"installed-version": "7.24.4", "latest-version": "7.23.7"}}
|
||||
return httpx.Response(200, json=data.get(r.url.path, []))
|
||||
|
||||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||||
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
||||
|
||||
async def fake_backup_ok(device_id):
|
||||
calls.append("backup")
|
||||
return "Бэкап загружен в S3: k.backup, k.rsc"
|
||||
|
||||
async def fake_backup_fail(device_id):
|
||||
calls.append("backup")
|
||||
raise RuntimeError("бэкап не удался")
|
||||
|
||||
# 1) версия канала (7.23.7) не совпала с подтверждённой пользователем -> задача failed, ничего не вызывается
|
||||
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
|
||||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.6"})
|
||||
await asyncio.sleep(0.2)
|
||||
j = jobs.get_job(jid)
|
||||
assert j.status == "failed" and "не совпадает" in j.message and calls == []
|
||||
|
||||
# 2) версия совпала -> бэкап выполняется до install, install вызван
|
||||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
|
||||
await asyncio.sleep(0.2)
|
||||
j = jobs.get_job(jid)
|
||||
assert j.status == "done" and calls == ["backup", "install"]
|
||||
|
||||
# 3) бэкап падает -> install не вызывается
|
||||
calls.clear()
|
||||
monkeypatch.setattr(ops, "run_backup", fake_backup_fail)
|
||||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
|
||||
await asyncio.sleep(0.2)
|
||||
j = jobs.get_job(jid)
|
||||
assert j.status == "failed" and calls == ["backup"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_api_endpoints(monkeypatch):
|
||||
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
|
||||
эндпоинт (не через общий /batch/{action}, у которого в Literal ros_downgrade нет)."""
|
||||
async def fake_downgrade(device_id, target_version):
|
||||
return f"откат до {target_version}"
|
||||
|
||||
monkeypatch.setitem(jobs.JOB_TYPES, "ros_downgrade", fake_downgrade)
|
||||
d1 = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||||
d2 = devices.create_device("r2", "10.0.0.2", 443, "admin", "pw")
|
||||
|
||||
with TestClient(create_app()) as c:
|
||||
h = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
assert c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={}).status_code == 422
|
||||
r = c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={"target_version": "7.23.7"})
|
||||
assert r.status_code == 202
|
||||
job_ids = r.json()["job_ids"]
|
||||
|
||||
# тело без target_version — если бы запрос ушёл в общий /batch/{action} (там target_version не нужен),
|
||||
# он был бы принят; 422 подтверждает, что обрабатывает именно типизированный /batch/ros_downgrade
|
||||
assert c.post("/api/v1/batch/ros_downgrade", headers=h, json={"device_ids": [d1.id]}).status_code == 422
|
||||
r = c.post("/api/v1/batch/ros_downgrade", headers=h,
|
||||
json={"device_ids": [d1.id, d2.id], "target_version": "7.23.7"})
|
||||
assert r.status_code == 202
|
||||
job_ids += r.json()["job_ids"]
|
||||
assert len(job_ids) == 3
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
for jid in job_ids:
|
||||
j = jobs.get_job(jid)
|
||||
assert j.type == "ros_downgrade" and j.status == "done" and j.message == "откат до 7.23.7"
|
||||
|
||||
|
||||
def test_ui_downgrade_dialog_and_mismatch(monkeypatch):
|
||||
"""Окно отката: неактивная кнопка, пока версия не введена (data-enables); несовпадающая версия — ошибка
|
||||
в окне, задача не создаётся."""
|
||||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||||
|
||||
def _set_status(device, status):
|
||||
with session_scope() as s:
|
||||
s.get(Device, device.id).status_json = json.dumps(status)
|
||||
|
||||
_set_status(d, {"channel": "long-term", "ros_installed": "7.24.4", "ros_latest": "7.23.7"})
|
||||
|
||||
with TestClient(create_app()) as c:
|
||||
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
htmx = {"HX-Request": "true"}
|
||||
page = c.get(f"/ui/dialog/downgrade?device_ids={d.id}", headers=htmx).text
|
||||
assert "r1" in page and 'data-enables="#downgrade-confirm"' in page and 'id="downgrade-confirm" disabled' in page
|
||||
|
||||
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.16"}, headers=htmx)
|
||||
assert "не совпадает" in r.text and 'id="modal-title"' in r.text
|
||||
assert jobs.list_jobs() == []
|
||||
|
||||
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.23.7"}, headers=htmx)
|
||||
assert r.headers.get("HX-Refresh") == "true"
|
||||
assert len(jobs.list_jobs()) == 1 and jobs.list_jobs()[0].type == "ros_downgrade"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_link_entities(monkeypatch):
|
||||
"""Журнал: у каждой записи свой ID, ссылка на ID сущности и актор; в журнал попадают только смены online/offline."""
|
||||
|
||||
Reference in new issue
Block a user