Групповое удаление бэкапов, поддержка CHR, строгое сравнение версий ROS
Групповое удаление бэкапов (docs/changes/013): - страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N / Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления; - backups.delete_many: все ключи проверяются до удаления, удаление параллельное; UI POST /backups/delete-many, API POST /api/v1/backups/delete; - выбор строк в app.js обобщён (data-select) для устройств и бэкапов. Поддержка CHR (docs/changes/014): - у CHR нет /system/routerboard (HTTP 400): устройство больше не считается недоступным, версия FW не показывается, обновление FW пропускается; - «есть обновление ROS» определяется строгим сравнением версий (на канале long-term последняя версия может быть старше установленной); - в таблице указана причина недоступности: авторизация / ошибка ответа / нет соединения. Тесты: 11 из 11. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c1841b61b
commit
0914209bf8
16 files changed
+194
-20
No files matched your search
@@ -239,6 +239,17 @@ async def download_backup(key: str):
|
||||
return RedirectResponse(s3.presign_get(key))
|
||||
|
||||
|
||||
class KeysIn(BaseModel):
|
||||
keys: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
@router.post("/backups/delete")
|
||||
async def delete_backups(body: KeysIn):
|
||||
"""Групповое удаление файлов из бакета: {"keys": [...]}."""
|
||||
deleted, failed = await backups.delete_many(body.keys)
|
||||
return {"deleted": deleted, "failed": failed}
|
||||
|
||||
|
||||
@router.delete("/backups", status_code=204)
|
||||
async def delete_backup(key: str):
|
||||
if not s3.key_allowed(key):
|
||||
|
||||
+29
-4
@@ -10,6 +10,31 @@ from app.ros.client import RosClient, RosError
|
||||
CHANNELS = ("stable", "long-term", "testing", "development")
|
||||
|
||||
|
||||
_PRERELEASE_RE = re.compile(r"(alpha|beta|rc)", re.I)
|
||||
|
||||
|
||||
def _ver_key(v: str | None) -> tuple:
|
||||
head = re.match(r"\s*([\d.]+)", v or "") # числовая часть до суффикса: '7.25rc1' -> '7.25'
|
||||
nums = [int(x) for x in re.findall(r"\d+", head.group(1) if head else "")][:4]
|
||||
return tuple(nums + [0] * (4 - len(nums))) + (0 if _PRERELEASE_RE.search(v or "") else 1,)
|
||||
|
||||
|
||||
def version_newer(latest: str | None, installed: str | None) -> bool:
|
||||
"""True, если latest действительно новее installed. Строгое сравнение: на канале long-term «последняя»
|
||||
версия может быть старше установленной — это не обновление. '7.25rc1' < '7.25'."""
|
||||
return bool(latest and installed) and _ver_key(latest) > _ver_key(installed)
|
||||
|
||||
|
||||
async def get_routerboard(c: RosClient) -> dict:
|
||||
"""system/routerboard; на CHR и x86 раздела нет (HTTP 400 no such command) — это не сбой устройства."""
|
||||
try:
|
||||
return await c.get("system/routerboard") or {}
|
||||
except RosError as e:
|
||||
if e.status == 400 and "no such command" in str(e):
|
||||
return {"routerboard": "false"}
|
||||
raise
|
||||
|
||||
|
||||
async def get_status(c: RosClient, check_updates: bool = True) -> dict:
|
||||
"""Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления.
|
||||
|
||||
@@ -18,7 +43,7 @@ async def get_status(c: RosClient, check_updates: bool = True) -> dict:
|
||||
"""
|
||||
resource, board, update = await asyncio.gather(
|
||||
c.get("system/resource"),
|
||||
c.get("system/routerboard"),
|
||||
get_routerboard(c),
|
||||
c.get("system/package/update"),
|
||||
)
|
||||
check_error = None
|
||||
@@ -105,8 +130,8 @@ async def install_ros_update(c: RosClient) -> str:
|
||||
await c.post("system/package/update/check-for-updates", timeout=120)
|
||||
u = await c.get("system/package/update")
|
||||
installed, latest = u.get("installed-version"), u.get("latest-version")
|
||||
if not latest or installed == latest:
|
||||
return f"Обновление не требуется (версия {installed})"
|
||||
if not version_newer(latest, installed):
|
||||
return f"Обновление не требуется (установлена {installed}, на канале {latest})"
|
||||
try:
|
||||
await c.post("system/package/update/install", timeout=15)
|
||||
except RosError as e:
|
||||
@@ -154,7 +179,7 @@ async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float =
|
||||
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
||||
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
||||
поэтому по нему завершение определить нельзя."""
|
||||
board = await c.get("system/routerboard")
|
||||
board = await get_routerboard(c)
|
||||
if str(board.get("routerboard", "false")) != "true":
|
||||
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
||||
if board.get("current-firmware") == board.get("upgrade-firmware"):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from app import s3
|
||||
@@ -47,3 +48,30 @@ async def search(device: str = "", group: str = "", kind: str = "", date_from: d
|
||||
async def list_backups(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
|
||||
date_to: date | None = None, q: str = "") -> list[dict]:
|
||||
return (await search(device, group, kind, date_from, date_to, q))[0]
|
||||
|
||||
|
||||
MAX_DELETE = 500
|
||||
|
||||
|
||||
async def delete_many(keys: list[str]) -> tuple[int, int]:
|
||||
"""Групповое удаление файлов из бакета. Все ключи проверяются до начала удаления
|
||||
(чужой ключ — ValueError, ничего не удаляется). Возвращает (удалено, не удалось)."""
|
||||
keys = list(dict.fromkeys(keys))
|
||||
if not keys:
|
||||
raise ValueError("Не выбрано ни одного файла")
|
||||
if len(keys) > MAX_DELETE:
|
||||
raise ValueError(f"За один раз можно удалить не более {MAX_DELETE} файлов")
|
||||
if not all(s3.key_allowed(k) for k in keys):
|
||||
raise ValueError("Недопустимый ключ")
|
||||
sem = asyncio.Semaphore(8)
|
||||
|
||||
async def one(key: str) -> bool:
|
||||
async with sem:
|
||||
try:
|
||||
await s3.delete_object(key)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — считаем неудачей одного файла, остальные удаляем
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(one(k) for k in keys))
|
||||
return sum(results), len(results) - sum(results)
|
||||
@@ -5,6 +5,7 @@ from app import security
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.models import Device, Group
|
||||
from app.ros.operations import version_newer
|
||||
from app.ros.client import RosClient
|
||||
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # имя попадает в ключи S3 — только безопасные символы
|
||||
@@ -129,7 +130,7 @@ def open_client(conn: Conn) -> RosClient:
|
||||
|
||||
|
||||
def has_ros_update(st: dict) -> bool:
|
||||
return bool(st.get("ros_latest") and st.get("ros_installed") and st["ros_latest"] != st["ros_installed"])
|
||||
return version_newer(st.get("ros_latest"), st.get("ros_installed"))
|
||||
|
||||
|
||||
def has_fw_update(st: dict) -> bool:
|
||||
|
||||
@@ -11,5 +11,6 @@ ICONS = {
|
||||
"archive": '<rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10h14V9M10 13h4"/>',
|
||||
"download": '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
|
||||
"router": '<rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 17h.01M11 17h.01M8 14l-2-6M16 14l2-6"/>',
|
||||
"trash": '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
|
||||
"arrow": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
}
|
||||
+13
-2
@@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import s3, security
|
||||
from app.config import get_settings
|
||||
from app.ros.operations import CHANNELS
|
||||
from app.ros.operations import CHANNELS, version_newer
|
||||
from app.services import backups, devices, groups, jobs, ops
|
||||
from app.ui.icons import ICONS
|
||||
|
||||
@@ -29,6 +29,7 @@ templates.env.filters["dt"] = lambda v: v.strftime("%Y-%m-%d %H:%M") if v else "
|
||||
templates.env.filters["size"] = lambda n: f"{n / 1024 / 1024:.1f} МБ" if n >= 1024 * 1024 else f"{n / 1024:.0f} КБ"
|
||||
templates.env.filters["plural"] = plural
|
||||
templates.env.globals["ICONS"] = ICONS
|
||||
templates.env.globals["ros_newer"] = version_newer
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
@@ -351,7 +352,7 @@ def _opt_date(value: str) -> date | None:
|
||||
|
||||
@router.get("/backups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
||||
async def backups_page(request: Request, device: str = "", group: str = "", kind: str = "",
|
||||
date_from: str = "", date_to: str = "", q: str = ""):
|
||||
date_from: str = "", date_to: str = "", q: str = "", deleted: int = -1, failed: int = 0):
|
||||
kind = kind if kind in ("backup", "rsc") else ""
|
||||
items, stats, error = [], {"total": 0, "size": 0}, None
|
||||
try:
|
||||
@@ -361,9 +362,19 @@ async def backups_page(request: Request, device: str = "", group: str = "", kind
|
||||
flt = dict(device=device, group=group, kind=kind, date_from=date_from, date_to=date_to, q=q)
|
||||
return _render(request, "backups.html", section="backups", items=items, stats=stats, error=error, flt=flt,
|
||||
flt_active=sum(1 for v in flt.values() if v), bucket=get_settings().s3_bucket,
|
||||
page_url="/backups" + ("?" + urlencode({k: v for k, v in flt.items() if v}) if any(flt.values()) else ""),
|
||||
deleted=deleted, failed=failed,
|
||||
devices=devices.list_devices(), groups=groups.list_groups())
|
||||
|
||||
|
||||
@router.post("/backups/delete-many", dependencies=[Depends(require_login)])
|
||||
async def backups_delete_many(key: list[str] = Form(default=[]), next: str = Form("/backups")):
|
||||
"""Групповое удаление выбранных файлов; возврат на страницу с теми же фильтрами и итогом."""
|
||||
deleted, failed = await backups.delete_many(key)
|
||||
back = next if next.startswith("/backups") else "/backups"
|
||||
return RedirectResponse(back + ("&" if "?" in back else "?") + urlencode({"deleted": deleted, "failed": failed}), status_code=303)
|
||||
|
||||
|
||||
@router.get("/backups/download", dependencies=[Depends(require_login)])
|
||||
async def backup_download(key: str):
|
||||
if not s3.key_allowed(key):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
// --- выбор строк: панель фильтров ⇄ панель действий над выбранными ---
|
||||
function syncSelection() {
|
||||
const boxes = $$("input[name=device_ids]");
|
||||
const boxes = $$("input[data-select]");
|
||||
const n = boxes.filter((b) => b.checked).length;
|
||||
boxes.forEach((b) => b.closest("tr")?.classList.toggle("sel", b.checked));
|
||||
const all = $("#check-all");
|
||||
@@ -54,8 +54,8 @@
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
const t = e.target;
|
||||
if (t.matches("input[name=device_ids]")) syncSelection();
|
||||
if (t.id === "check-all") { $$("input[name=device_ids]").forEach((b) => (b.checked = t.checked)); syncSelection(); }
|
||||
if (t.matches("input[data-select]")) syncSelection();
|
||||
if (t.id === "check-all") { $$("input[data-select]").forEach((b) => (b.checked = t.checked)); syncSelection(); }
|
||||
if (t.closest("#dev-filters")) syncFilters();
|
||||
// «Новая группа…» в списке групп окна устройства
|
||||
if (t.matches("select[name=group_id][data-newgroup]")) $("#new-group-field").hidden = t.value !== "__new__";
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
if (t.closest("#clear-sel")) { $$("input[name=device_ids]").forEach((b) => (b.checked = false)); syncSelection(); }
|
||||
if (t.closest("#clear-sel")) { $$("input[data-select]").forEach((b) => (b.checked = false)); syncSelection(); }
|
||||
// меню: закрывается по клику вне и после выбора пункта; открыто не более одного
|
||||
if (t.closest("details.sub > summary")) return; // раскрытие подменю («Сменить канал…») меню не закрывает
|
||||
const menu = t.closest("details.menu");
|
||||
|
||||
@@ -188,6 +188,7 @@ dialog.modal::backdrop { background: var(--backdrop); }
|
||||
.check { display: flex; align-items: flex-start; gap: 10px; font: 500 14px/1.3 var(--font); }
|
||||
.check small { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
|
||||
.note { padding: 10px 12px; border-radius: 8px; background: var(--warn-b); color: var(--warn-t); font: 400 13px/1.4 var(--font); }
|
||||
.form-ok { padding: 10px 12px; border-radius: 8px; background: var(--ok-b); color: var(--ok-t); font: 500 13px/1.4 var(--font); }
|
||||
.form-err { padding: 10px 12px; border-radius: 8px; background: var(--bad-b); color: var(--bad-t); font: 500 13px/1.4 var(--font); }
|
||||
.page-card { max-width: 560px; margin: 24px auto; }
|
||||
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set st = d.status %}{% set down = d.online is false %}
|
||||
{% set ros_new = st.ros_latest and st.ros_installed and st.ros_latest != st.ros_installed %}
|
||||
{% set ros_new = ros_newer(st.ros_latest, st.ros_installed) %}
|
||||
{% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" aria-label="Выбрать {{ d.name }}"></td>
|
||||
<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 }}">Нет соединения</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>
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
<div class="actions"><a class="btn secondary" href="/backups?{{ request.url.query }}">{{ ui.icon("refresh") }}Обновить список</a></div>
|
||||
</div>
|
||||
|
||||
{% if deleted >= 0 %}<div class="{{ 'form-err' if failed else 'form-ok' }}" role="status" style="margin-bottom:16px">
|
||||
Удалено файлов: {{ deleted }}{% if failed %}; не удалось удалить: {{ failed }}{% endif %}</div>{% endif %}
|
||||
|
||||
<form id="bulk-form" method="post" action="/backups/delete-many" hidden><input type="hidden" name="next" value="{{ page_url }}"></form>
|
||||
|
||||
<div class="card">
|
||||
<div class="tools">
|
||||
<div class="tools" id="tools-filters">
|
||||
<form method="get" action="/backups">
|
||||
<select class="field {{ 'on' if flt.group }}" name="group" onchange="this.form.submit()" aria-label="Группа" style="width:170px">
|
||||
<option value="">Группа: любая</option>
|
||||
@@ -33,14 +38,22 @@
|
||||
{% if flt_active %}<a class="btn ghost" href="/backups">Сбросить ({{ flt_active }})</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
<div class="tools bulk" id="tools-bulk" hidden>
|
||||
<span class="sel-count">Выбрано: <span id="sel-count">0</span></span>
|
||||
<button type="submit" form="bulk-form" class="btn danger"
|
||||
onclick="return confirm('Удалить выбранные файлы (' + document.querySelectorAll('input[name=key]:checked').length + ') из бакета? Действие необратимо.')">{{ ui.icon("trash") }}Удалить</button>
|
||||
<span class="grow"></span>
|
||||
<button type="button" id="clear-sel" class="btn ghost-n">{{ ui.icon("x") }}Снять выбор</button>
|
||||
</div>
|
||||
{% if error %}<div class="form-err" role="alert" style="margin:0 16px 16px">Ошибка S3: {{ error }}</div>{% endif %}
|
||||
<div class="table-wrap">
|
||||
<table class="grid files">
|
||||
<colgroup><col style="width:20%"><col style="width:140px"><col style="width:100px"><col><col style="width:100px"><col style="width:170px"><col style="width:104px"></colgroup>
|
||||
<thead><tr><th>Устройство</th><th>Группа</th><th>Тип</th><th>Файл</th><th>Размер</th><th>Изменён (UTC)</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||||
<colgroup><col style="width:44px"><col style="width:20%"><col style="width:140px"><col style="width:100px"><col><col style="width:100px"><col style="width:170px"><col style="width:104px"></colgroup>
|
||||
<thead><tr><th><input type="checkbox" id="check-all" aria-label="Выбрать все"></th><th>Устройство</th><th>Группа</th><th>Тип</th><th>Файл</th><th>Размер</th><th>Изменён (UTC)</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||||
<tbody>
|
||||
{% for i in items %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="key" value="{{ i.key }}" form="bulk-form" data-select aria-label="Выбрать {{ i.key.split('/')[-1] }}"></td>
|
||||
<td style="font-weight:600">{{ i.device or "—" }}</td>
|
||||
<td class="muted">{{ i.group or "—" }}</td>
|
||||
<td><span class="badge">{{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }}</span></td>
|
||||
@@ -60,7 +73,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="empty">Файлов не найдено.</td></tr>
|
||||
<tr><td colspan="8" class="empty">Файлов не найдено.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in new issue
Block a user