Состояния колонки «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:
ayurishchevandClaude Opus 5.5 committed 2026-09-28 17:28:01 +03:00
1 parent 0bda0038d0
commit ae826fc562
14 files changed
+437 -13

No files matched your search

+7 -2
View File
@@ -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`).
+23
View File
@@ -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
View File
@@ -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)?$")
+15
View File
@@ -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"])
+1
View File
@@ -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()
+13
View File
@@ -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
View File
@@ -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)])
+8 -3
View File
@@ -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>
+38
View File
@@ -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>
+1 -1
View File
@@ -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>
+1
View File
@@ -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>
+95
View File
@@ -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 до перезагрузки.
+35
View File
@@ -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 пользователем на момент коммита не подтверждена.
+115
View File
@@ -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."""