Files
ros_control/app/ui/templates/_devices.html
T
ayurishchevandClaude Opus 5.5 5f7bd6588a Состояния «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>
2026-09-29 00:04:51 +03:00

85 lines
7.5 KiB
HTML

{% import "_ui.html" as ui %}
{% if oob %}<div class="sub" id="dev-sub" hx-swap-oob="true">{% include "_dev_sub.html" %}</div>{% endif %}
<div class="table-wrap">
<table class="grid devices">
<colgroup><col style="width:44px"><col><col style="width:110px"><col style="width:110px"><col style="width:100px"><col style="width:120px"><col style="width:90px"><col style="width:120px"><col style="width:100px"><col style="width:150px"><col style="width:52px"></colgroup>
<thead>
<tr>
<th><input type="checkbox" id="check-all" aria-label="Выбрать все"></th>
<th>Устройство</th><th>Группа</th><th>Канал</th><th>ROS</th><th>Upgrade ROS</th><th>FW</th><th>Upgrade FW</th><th>Uptime</th><th>Запрос бэкапа (UTC)</th><th><span class="sr-only">Действия</span></th>
</tr>
</thead>
<tbody>
{% for d in devices %}
{% set st = d.status %}{% set down = d.online is false %}
{% set rst = ros_state(st) %}
{% 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>
<div class="dev">
<span class="dot {{ 'on' if d.online else ('off' if down else '') }}" title="{{ d.last_error or ('online, опрошено ' ~ (d.status_at|dt)) }}"></span>
<div style="min-width:0">
<div class="dev-name"><a class="dev-link" href="/devices/{{ d.id }}/edit" hx-get="/ui/dialog/device/{{ d.id }}" hx-target="#modal-body"
title="{{ d.note or 'Изменить устройство' }}">{{ d.name }}</a></div>
{% if down %}<div class="dev-sub bad" title="{{ d.last_error }}">{% if "HTTP 401" in (d.last_error or "") %}Ошибка авторизации{% elif "HTTP " in (d.last_error or "") %}Устройство ответило ошибкой{% else %}Нет соединения{% endif %}</div>
{% else %}<div class="dev-sub">{{ d.host }}:{{ d.port }}{% if st.model %} · {{ st.model }}{% endif %}</div>{% endif %}
</div>
</div>
</td>
<td class="muted">{{ group_names.get(d.group_id) or "—" }}</td>
<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 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>
<td>
{% 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>
<td class="c-small">{{ d.last_backup_requested_at|dt }}</td>
<td class="c-menu">
<details class="menu right">
<summary class="btn ghost-n icon" aria-label="Действия для {{ d.name }}" title="Действия">{{ ui.icon("more") }}</summary>
<div class="menu-panel w270">
<button type="button" class="mi" hx-post="/ui/devices/{{ d.id }}/refresh" hx-include="#dev-filters" hx-target="#devices">Обновить статус</button>
<button type="button" class="mi" hx-post="/ui/devices/{{ d.id }}/backup" hx-target="#jobs">Создать бэкап</button>
<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>
{% 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>
<div class="sub-list">
{% for c in channels %}<button type="button" class="mi {{ 'on' if c == st.channel }}" hx-post="/ui/devices/{{ d.id }}/channel" hx-vals='{"channel": "{{ c }}"}' hx-include="#dev-filters" hx-target="#devices">{{ c }}</button>{% endfor %}
</div>
</details>
<button type="button" class="mi" hx-get="/ui/dialog/device/{{ d.id }}" hx-target="#modal-body">Изменить устройство</button>
</div>
</details>
</td>
</tr>
{% else %}
<tr><td colspan="11" class="empty">{{ "Нет устройств, подходящих под фильтр." if filtered else "Устройств пока нет. Добавьте первое." }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tfoot">Показано {{ devices|length }} из {{ total }} {{ total|plural("устройства", "устройств", "устройств") }}</div>