Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD
Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
перезагрузка; API POST /devices/{id}/firmware/downgrade и
/batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.
Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dda12e06c
commit
5f7bd6588a
16 files changed
+393
-52
No files matched your search
@@ -211,6 +211,13 @@ async def upgrade_firmware(device_id: str):
|
||||
return {"job_ids": await jobs.start_jobs("fw_update", [device_id])}
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/firmware/downgrade", status_code=202)
|
||||
async def downgrade_firmware(device_id: str, body: DowngradeIn):
|
||||
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (устройство
|
||||
перезагрузится); версия проверяется на устройстве в задаче — как и /update/downgrade."""
|
||||
return {"job_ids": await jobs.start_jobs("fw_downgrade", [device_id], {"target_version": body.target_version})}
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/update/downgrade", status_code=202)
|
||||
async def downgrade_update(device_id: str, body: DowngradeIn):
|
||||
"""Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на
|
||||
@@ -226,6 +233,14 @@ async def batch_ros_downgrade(body: BatchDowngradeIn):
|
||||
return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})}
|
||||
|
||||
|
||||
@router.post("/batch/fw_downgrade", status_code=202)
|
||||
async def batch_fw_downgrade(body: BatchDowngradeIn):
|
||||
"""Изменение 027: групповой откат прошивки — отдельный эндпоинт с обязательной target_version, по образцу
|
||||
/batch/ros_downgrade; регистрируется раньше /batch/{action}."""
|
||||
device_ids = await asyncio.to_thread(body.resolve)
|
||||
return {"job_ids": await jobs.start_jobs("fw_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):
|
||||
"""Групповая операция над списком устройств."""
|
||||
|
||||
+53
-11
@@ -205,16 +205,12 @@ async def _booted_at(c: RosClient) -> datetime | None:
|
||||
return now - timedelta(seconds=up) if now and up is not None else None
|
||||
|
||||
|
||||
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
||||
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
||||
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
||||
поэтому по нему завершение определить нельзя."""
|
||||
board = await get_routerboard(c)
|
||||
if str(board.get("routerboard", "false")) != "true":
|
||||
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
||||
if board.get("current-firmware") == board.get("upgrade-firmware"):
|
||||
return f"Прошивка актуальна ({board.get('current-firmware')})"
|
||||
|
||||
async def _flash_firmware(c: RosClient, wait_seconds: int, poll: float) -> str | None:
|
||||
"""Записывает встроенную в ROS прошивку (POST system/routerboard/upgrade) и ждёт появления FW_DONE_MSG
|
||||
в журнале, либо обнаруживает, что запись уже была сделана после последней загрузки (изменение 027: общая
|
||||
часть upgrade_firmware и downgrade_firmware — команда и признак завершения одни и те же, отличается только
|
||||
итоговое сообщение). Возвращает готовое сообщение для уже записанной прошивки, иначе None — сообщение о
|
||||
свежей записи формирует вызывающий код (текст различается для обновления и отката)."""
|
||||
before = await _fw_done_entries(c)
|
||||
booted = await _booted_at(c)
|
||||
if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before):
|
||||
@@ -227,11 +223,57 @@ async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float =
|
||||
for _ in range(int(wait_seconds / poll) if poll else 1000):
|
||||
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
|
||||
await _reboot(c)
|
||||
return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
|
||||
return None
|
||||
await asyncio.sleep(poll)
|
||||
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
|
||||
|
||||
|
||||
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
||||
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
||||
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
||||
поэтому по нему завершение определить нельзя."""
|
||||
board = await get_routerboard(c)
|
||||
if str(board.get("routerboard", "false")) != "true":
|
||||
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
||||
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
|
||||
if current == upgrade:
|
||||
return f"Прошивка актуальна ({current})"
|
||||
if version_newer(current, upgrade):
|
||||
# изменение 027: плата записана прошивкой новее встроенной в установленную ROS (например, после отката
|
||||
# ROS) — это не обновление, а откат; штатное обновление его не выполняет
|
||||
return (f"Прошивка платы {current} новее встроенной в ROS {upgrade} — обновление не требуется; "
|
||||
f"откат — «Откатить прошивку…»")
|
||||
|
||||
already = await _flash_firmware(c, wait_seconds, poll)
|
||||
return already or f"Прошивка обновлена до {upgrade}, перезагрузка отправлена"
|
||||
|
||||
|
||||
async def check_fw_downgrade(c: RosClient, target_version: str) -> tuple[str | None, str | None]:
|
||||
"""Изменение 027: проверяет осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS —
|
||||
по образцу check_downgrade. Не RouterBOARD — откат недоступен; встроенная версия должна совпадать с
|
||||
подтверждённой пользователем и быть действительно старше записанной на плате (иначе это не откат). Запись не
|
||||
выполняет — чтобы не делать бэкап устройства, которое откатывать нельзя. Возвращает (current, upgrade)."""
|
||||
board = await get_routerboard(c)
|
||||
if str(board.get("routerboard", "false")) != "true":
|
||||
raise RosError("Устройство без RouterBOARD firmware (например, CHR) — откат прошивки недоступен")
|
||||
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
|
||||
if upgrade != target_version:
|
||||
raise RosError(f"Прошивка в ROS {upgrade} не совпадает с подтверждённой {target_version} — откат отменён")
|
||||
if not version_newer(current, upgrade):
|
||||
raise RosError(f"Прошивка в ROS {upgrade} не старше записанной {current} — это не откат")
|
||||
return current, upgrade
|
||||
|
||||
|
||||
async def downgrade_firmware(c: RosClient, target_version: str, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
||||
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (после
|
||||
отката ROS, например изменение 023, плата остаётся на прежней прошивке). Технически — та же команда
|
||||
/system/routerboard/upgrade, что и обычное обновление: она записывает версию, встроенную в установленную
|
||||
ROS, в том числе более старую."""
|
||||
current, upgrade = await check_fw_downgrade(c, target_version)
|
||||
already = await _flash_firmware(c, wait_seconds, poll)
|
||||
return already or f"Откат прошивки {current} → {upgrade} записан, перезагрузка отправлена"
|
||||
|
||||
|
||||
async def _reboot(c: RosClient) -> None:
|
||||
try:
|
||||
await c.post("system/reboot", timeout=10)
|
||||
|
||||
+15
-1
@@ -170,8 +170,22 @@ def ros_state(st: dict) -> str:
|
||||
return "current"
|
||||
|
||||
|
||||
def fw_state(st: dict) -> str:
|
||||
"""Состояние колонки «Upgrade FW» (изменение 027): unknown — нет fw_current/fw_upgrade (CHR или не опрошено);
|
||||
update — в установленную ROS встроена более новая прошивка; downgrade — записанная на плате прошивка новее
|
||||
встроенной в ROS (например, после отката ROS, изменение 023 — плата не откатывается вместе с ROS); иначе
|
||||
current. По образцу ros_state."""
|
||||
if not (st.get("fw_current") and st.get("fw_upgrade")):
|
||||
return "unknown"
|
||||
if version_newer(st["fw_upgrade"], st["fw_current"]):
|
||||
return "update"
|
||||
if version_newer(st["fw_current"], st["fw_upgrade"]):
|
||||
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"])
|
||||
return fw_state(st) == "update"
|
||||
|
||||
|
||||
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
|
||||
|
||||
@@ -13,6 +13,7 @@ JOB_TYPES = {
|
||||
"fw_update": ops.run_fw_update,
|
||||
"set_channel": ops.run_set_channel,
|
||||
"ros_downgrade": ops.run_ros_downgrade,
|
||||
"fw_downgrade": ops.run_fw_downgrade, # изменение 027
|
||||
}
|
||||
|
||||
_tasks: set[asyncio.Task] = set()
|
||||
|
||||
@@ -167,3 +167,16 @@ async def run_ros_downgrade(device_id: str, target_version: str) -> str:
|
||||
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}"
|
||||
|
||||
|
||||
async def run_fw_downgrade(device_id: str, target_version: str) -> str:
|
||||
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS — та же
|
||||
последовательность, что и run_ros_downgrade: 1) проверка совпадения с подтверждённой версией и того, что она
|
||||
действительно старше записанной; 2) обязательный бэкап (сбой прерывает задачу, запись не выполняется);
|
||||
3) повторная проверка и запись на новом подключении."""
|
||||
async with devices.open_client(await _conn(device_id)) as c:
|
||||
await ros.check_fw_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_firmware(c, target_version)
|
||||
return f"{install_msg}. {backup_msg}"
|
||||
+67
-17
@@ -48,6 +48,7 @@ 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
|
||||
templates.env.globals["fw_state"] = devices.fw_state # изменение 027
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
@@ -236,10 +237,15 @@ async def device_action(request: Request, device_id: str, action: str, channel:
|
||||
|
||||
|
||||
@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 и версия канала совпадает с введённой; иначе окно остаётся
|
||||
открытым с ошибкой — задачи не создаются."""
|
||||
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default=""),
|
||||
kind: str = Form(default="ros")):
|
||||
"""Откат ROS/FW для выбранных устройств (окно «Откатить…», параметризовано kind — изменение 027: без kind
|
||||
поведение и адреса как раньше): из выбранных берутся только те, у кого в кэше статуса соответствующее
|
||||
состояние downgrade и целевая версия совпадает с введённой; иначе окно остаётся открытым с ошибкой —
|
||||
задачи не создаются."""
|
||||
if kind not in DOWNGRADE_KINDS:
|
||||
raise ValueError(f"Неизвестный вид отката: {kind}")
|
||||
k = DOWNGRADE_KINDS[kind]
|
||||
target_version = target_version.strip()
|
||||
wanted = set(device_ids)
|
||||
|
||||
@@ -247,10 +253,10 @@ async def downgrade(request: Request, device_ids: list[str] = Form(default=[]),
|
||||
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]
|
||||
matched = [d for d in devs if k["state"](d.status) == "downgrade" and d.status.get(k["target_key"]) == 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 _downgrade_dialog(request, devs, k["mismatch_error"], kind)
|
||||
await jobs.start_jobs(k["job_type"], [d.id for d in matched], {"target_version": target_version})
|
||||
return _done(request)
|
||||
|
||||
|
||||
@@ -323,32 +329,76 @@ 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 до версии канала ---
|
||||
# --- откат ROS до версии канала / откат FW до версии, встроенной в ROS (изменение 027) ---
|
||||
|
||||
def _downgrade_split(devs: list) -> tuple[list, list]:
|
||||
# Описания видов отката для общего окна _dialog_downgrade.html: функция состояния, ключи установленной/целевой
|
||||
# версии в кэше статуса, тип фоновой задачи и тексты. Без kind (?kind= отсутствует, поле формы по умолчанию)
|
||||
# используется "ros" — адреса и поведение отката ROS не меняются.
|
||||
DOWNGRADE_KINDS: dict[str, dict] = {
|
||||
"ros": dict(
|
||||
title="Откатить ROS до версии канала",
|
||||
state=devices.ros_state,
|
||||
installed_key="ros_installed",
|
||||
target_key="ros_latest",
|
||||
col_label="Установлено → канал",
|
||||
extra_label="Канал",
|
||||
extra_key="channel",
|
||||
job_type="ros_downgrade",
|
||||
hint="должна совпадать с версией канала выбранных устройств",
|
||||
warn=("Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки "
|
||||
"устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. "
|
||||
"Если версия канала у выбранных устройств различается, откатятся только те, чья версия канала "
|
||||
"совпадает с введённой ниже — остальные останутся без изменений."),
|
||||
mismatch_reason="версия канала не старше установленной",
|
||||
mismatch_error="Версия не совпадает с версией канала выбранных устройств",
|
||||
),
|
||||
"fw": dict(
|
||||
title="Откатить прошивку до версии ROS",
|
||||
state=devices.fw_state,
|
||||
installed_key="fw_current",
|
||||
target_key="fw_upgrade",
|
||||
col_label="Записано → в ROS",
|
||||
extra_label=None,
|
||||
extra_key=None,
|
||||
job_type="fw_downgrade",
|
||||
hint="должна совпадать с версией, встроенной в установленную ROS выбранных устройств",
|
||||
warn=("Перед записью создаётся резервная копия устройства; без неё откат не выполняется. После записи "
|
||||
"устройство перезагрузится. Прошивка берётся из установленной ROS — не с серверов MikroTik. Если "
|
||||
"встроенная версия у выбранных устройств различается, откатятся только те, чья версия совпадает "
|
||||
"с введённой ниже — остальные останутся без изменений."),
|
||||
mismatch_reason="версия в ROS не старше записанной",
|
||||
mismatch_error="Версия не совпадает с версией в ROS выбранных устройств",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _downgrade_split(devs: list, kind: str = "ros") -> tuple[list, list]:
|
||||
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
||||
k = DOWNGRADE_KINDS[kind]
|
||||
affected, skipped = [], []
|
||||
for d in devs:
|
||||
rst = devices.ros_state(d.status)
|
||||
if rst == "downgrade":
|
||||
st = k["state"](d.status)
|
||||
if st == "downgrade":
|
||||
affected.append(d)
|
||||
else:
|
||||
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
|
||||
reason = "нет данных проверки" if st in ("unknown", "check_error") else k["mismatch_reason"]
|
||||
skipped.append((d, reason))
|
||||
return affected, skipped
|
||||
|
||||
|
||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
|
||||
affected, skipped = _downgrade_split(devs)
|
||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None, kind: str = "ros"):
|
||||
affected, skipped = _downgrade_split(devs, kind)
|
||||
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
|
||||
skipped=skipped, error=error)
|
||||
skipped=skipped, error=error, kind=kind, k=DOWNGRADE_KINDS[kind])
|
||||
|
||||
|
||||
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
||||
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])):
|
||||
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[]), kind: str = "ros"):
|
||||
if kind not in DOWNGRADE_KINDS:
|
||||
raise ValueError(f"Неизвестный вид отката: {kind}")
|
||||
wanted = set(device_ids)
|
||||
devs = [d for d in devices.list_devices() if d.id in wanted]
|
||||
return _downgrade_dialog(request, devs)
|
||||
return _downgrade_dialog(request, devs, kind=kind)
|
||||
|
||||
|
||||
# --- группы ---
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{% for d in devices %}
|
||||
{% set st = d.status %}{% set down = d.online is false %}
|
||||
{% set rst = ros_state(st) %}
|
||||
{% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %}
|
||||
{% set fst = fw_state(st) %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
|
||||
<td>
|
||||
@@ -39,8 +39,9 @@
|
||||
</td>
|
||||
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
|
||||
<td>
|
||||
{% if down or not (st.fw_current and st.fw_upgrade) %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
|
||||
{% elif fw_new %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
|
||||
{% if down or fst == "unknown" %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
|
||||
{% elif fst == "update" %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
|
||||
{% elif fst == "downgrade" %}<span class="badge" title="Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}. Работе обычно не мешает; откат — «Откатить прошивку…»">в ROS: {{ st.fw_upgrade }}</span>
|
||||
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
|
||||
</td>
|
||||
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
|
||||
@@ -59,6 +60,9 @@
|
||||
{% 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>
|
||||
{% if fst == "downgrade" %}
|
||||
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw&device_ids={{ d.id }}" hx-target="#modal-body">Откатить прошивку…<small>Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}</small></button>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<details class="sub">
|
||||
<summary class="mi">Сменить канал…</summary>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
|
||||
{# Окно осознанного отката ROS/FW: одно устройство или группа (devs — исходный выбор), параметризовано видом
|
||||
отката kind (ros|fw, изменение 027; по умолчанию ros — прежнее поведение и адреса без изменений). #}
|
||||
{% 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>
|
||||
<h2 id="modal-title">{{ k.title }}</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 %}
|
||||
<input type="hidden" name="kind" value="{{ kind }}">
|
||||
{% if affected %}
|
||||
<table class="grid" style="width:100%">
|
||||
<thead><tr><th>Устройство</th><th>Установлено → канал</th><th>Канал</th></tr></thead>
|
||||
<thead><tr><th>Устройство</th><th>{{ k.col_label }}</th>{% if k.extra_label %}<th>{{ k.extra_label }}</th>{% endif %}</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>
|
||||
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status[k.installed_key] }} → {{ d.status[k.target_key] }}</td>{% if k.extra_label %}<td>{{ d.status[k.extra_key] }}</td>{% endif %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -23,11 +25,8 @@
|
||||
<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>
|
||||
<div class="note">{{ k.warn }}</div>
|
||||
<label class="lbl"><span>Введите целевую версию <small>{{ k.hint }}</small></span>
|
||||
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
|
||||
</div>
|
||||
<footer class="modal-foot">
|
||||
|
||||
@@ -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": "Смена канала", "ros_downgrade": "Откат ROS"}.get(j.type, j.type) }}</td>
|
||||
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS", "fw_downgrade": "Откат FW"}.get(j.type, j.type) }}</td>
|
||||
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
|
||||
<td class="msg">{{ j.message }}</td>
|
||||
<td class="c-small">{{ j.created_at|dt }}</td>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<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>
|
||||
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить прошивку до версии ROS…<small>Обязателен бэкап; устройство перезагрузится</small></button>
|
||||
</div>
|
||||
</details>
|
||||
<details class="menu">
|
||||
|
||||
Reference in new issue
Block a user