Групповое удаление бэкапов (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>
78 lines
3.7 KiB
Python
78 lines
3.7 KiB
Python
"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
|
|
import asyncio
|
|
from datetime import date
|
|
|
|
from app import s3
|
|
from app.services import devices, groups
|
|
|
|
|
|
def _kind(key: str) -> str:
|
|
return "backup" if key.endswith(".backup") else "rsc" if key.endswith(".rsc") else "other"
|
|
|
|
|
|
async def search(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
|
|
date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]:
|
|
"""Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}).
|
|
Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства.
|
|
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе id группы.
|
|
Даты — по времени изменения объекта (UTC), включительно."""
|
|
group_names = {g["id"]: g["name"] for g in groups.list_groups()}
|
|
device_group = {d.name: group_names.get(d.group_id) for d in devices.list_devices()}
|
|
want_group = group_names.get(int(group)) if group.isdigit() else None
|
|
|
|
everything = await s3.list_backups(None)
|
|
stats = {"total": len(everything), "size": sum(i["size"] for i in everything)}
|
|
q = q.strip().lower()
|
|
out = []
|
|
for item in everything:
|
|
parts = item["key"].split("/")
|
|
name = parts[1] if len(parts) >= 3 else ""
|
|
item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"])}
|
|
if device and name != device:
|
|
continue
|
|
if group == "none" and item["group"] is not None:
|
|
continue
|
|
if group.isdigit() and (want_group is None or item["group"] != want_group):
|
|
continue
|
|
if kind and item["kind"] != kind:
|
|
continue
|
|
day = item["last_modified"].date()
|
|
if (date_from and day < date_from) or (date_to and day > date_to):
|
|
continue
|
|
if q and q not in item["key"].lower():
|
|
continue
|
|
out.append(item)
|
|
return out, stats
|
|
|
|
|
|
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)
|