Групповое удаление бэкапов (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>
270 lines
8.2 KiB
Python
270 lines
8.2 KiB
Python
from datetime import date, datetime
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app import s3
|
|
from app.ros.operations import CHANNELS
|
|
from app.security import require_api_token
|
|
from app.services import backups, devices, groups, jobs, ops
|
|
|
|
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_token)], tags=["api"])
|
|
|
|
|
|
class DeviceIn(BaseModel):
|
|
name: str
|
|
host: str
|
|
port: int = 443
|
|
username: str
|
|
password: str
|
|
verify_tls: bool = False
|
|
use_tls: bool = True # False = HTTP (сервис www, порт 80): пароль идёт открытым текстом
|
|
group_id: int | None = None
|
|
note: str | None = None
|
|
|
|
|
|
class DevicePatch(BaseModel):
|
|
name: str | None = None
|
|
host: str | None = None
|
|
port: int | None = None
|
|
username: str | None = None
|
|
password: str | None = None
|
|
verify_tls: bool | None = None
|
|
use_tls: bool | None = None
|
|
group_id: int | None = None # явный null — открепить от группы
|
|
note: str | None = None # пустая строка или null — очистить примечание
|
|
|
|
|
|
class DeviceOut(BaseModel):
|
|
"""Пароль устройства в ответы не попадает."""
|
|
id: int
|
|
name: str
|
|
host: str
|
|
port: int
|
|
username: str
|
|
verify_tls: bool
|
|
use_tls: bool
|
|
group_id: int | None
|
|
note: str | None
|
|
online: bool | None
|
|
status: dict
|
|
status_at: datetime | None
|
|
last_error: str | None
|
|
last_backup_requested_at: datetime | None
|
|
|
|
@classmethod
|
|
def of(cls, d) -> "DeviceOut":
|
|
return cls(**{k: getattr(d, k) for k in cls.model_fields})
|
|
|
|
|
|
class JobOut(BaseModel):
|
|
id: int
|
|
device_id: int | None
|
|
device_name: str
|
|
type: str
|
|
status: str
|
|
message: str
|
|
created_at: datetime
|
|
finished_at: datetime | None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ChannelIn(BaseModel):
|
|
channel: Literal[CHANNELS] # type: ignore[valid-type]
|
|
|
|
|
|
class GroupIn(BaseModel):
|
|
name: str
|
|
|
|
|
|
class GroupOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
device_count: int = 0
|
|
|
|
|
|
class BatchIn(BaseModel):
|
|
"""Устройства, над которыми выполняется операция: список id и/или целая группа."""
|
|
device_ids: list[int] = Field(default_factory=list)
|
|
group_id: int | None = None
|
|
|
|
def resolve(self) -> list[int]:
|
|
ids = list(self.device_ids)
|
|
if self.group_id is not None:
|
|
groups.get_group(self.group_id) # 404, если группы нет
|
|
ids += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
|
|
ids = list(dict.fromkeys(ids))
|
|
if not ids:
|
|
raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id")
|
|
return ids
|
|
|
|
|
|
class BatchChannelIn(BatchIn):
|
|
channel: Literal[CHANNELS] # type: ignore[valid-type]
|
|
|
|
|
|
# --- устройства ---
|
|
|
|
@router.get("/devices", response_model=list[DeviceOut])
|
|
async def list_devices(
|
|
group: str = "", # "" — все, "none" — без группы, иначе id группы
|
|
q: str = "",
|
|
status: Literal["", "online", "offline"] = "",
|
|
updates: Literal["", "ros", "fw", "any", "none"] = "",
|
|
channel: str = "",
|
|
):
|
|
found = devices.filter_devices(devices.list_devices(), group, q, status, updates, channel)
|
|
return [DeviceOut.of(d) for d in found]
|
|
|
|
|
|
# --- группы ---
|
|
|
|
@router.get("/groups", response_model=list[GroupOut])
|
|
async def list_groups():
|
|
return groups.list_groups()
|
|
|
|
|
|
@router.post("/groups", response_model=GroupOut, status_code=201)
|
|
async def create_group(body: GroupIn):
|
|
g = groups.create_group(body.name)
|
|
return GroupOut(id=g.id, name=g.name)
|
|
|
|
|
|
@router.patch("/groups/{group_id}", response_model=GroupOut)
|
|
async def rename_group(group_id: int, body: GroupIn):
|
|
groups.rename_group(group_id, body.name)
|
|
return next(g for g in groups.list_groups() if g["id"] == group_id)
|
|
|
|
|
|
@router.delete("/groups/{group_id}", status_code=204)
|
|
async def delete_group(group_id: int):
|
|
groups.delete_group(group_id)
|
|
|
|
|
|
@router.post("/devices", response_model=DeviceOut, status_code=201)
|
|
async def create_device(body: DeviceIn):
|
|
return DeviceOut.of(devices.create_device(**body.model_dump()))
|
|
|
|
|
|
@router.get("/devices/{device_id}", response_model=DeviceOut)
|
|
async def get_device(device_id: int):
|
|
return DeviceOut.of(devices.get_device(device_id))
|
|
|
|
|
|
@router.patch("/devices/{device_id}", response_model=DeviceOut)
|
|
async def patch_device(device_id: int, body: DevicePatch):
|
|
return DeviceOut.of(devices.update_device(device_id, **body.model_dump(exclude_unset=True)))
|
|
|
|
|
|
@router.delete("/devices/{device_id}", status_code=204)
|
|
async def delete_device(device_id: int):
|
|
devices.delete_device(device_id)
|
|
|
|
|
|
@router.post("/devices/{device_id}/refresh", response_model=DeviceOut)
|
|
async def refresh_device(device_id: int):
|
|
devices.get_device(device_id)
|
|
await ops.refresh_status(device_id)
|
|
return DeviceOut.of(devices.get_device(device_id))
|
|
|
|
|
|
@router.post("/devices/refresh", response_model=list[DeviceOut])
|
|
async def refresh_all():
|
|
await ops.refresh_many()
|
|
return [DeviceOut.of(d) for d in devices.list_devices()]
|
|
|
|
|
|
# --- операции (долгие — через jobs) ---
|
|
|
|
@router.post("/devices/{device_id}/backups", status_code=202)
|
|
async def create_backup(device_id: int):
|
|
return {"job_ids": jobs.start_jobs("backup", [device_id])}
|
|
|
|
|
|
@router.put("/devices/{device_id}/update/channel", response_model=DeviceOut)
|
|
async def put_channel(device_id: int, body: ChannelIn):
|
|
devices.get_device(device_id)
|
|
await ops.set_channel(device_id, body.channel)
|
|
return DeviceOut.of(devices.get_device(device_id))
|
|
|
|
|
|
@router.post("/devices/{device_id}/update/install", status_code=202)
|
|
async def install_update(device_id: int):
|
|
return {"job_ids": jobs.start_jobs("ros_update", [device_id])}
|
|
|
|
|
|
@router.post("/devices/{device_id}/firmware/upgrade", status_code=202)
|
|
async def upgrade_firmware(device_id: int):
|
|
return {"job_ids": jobs.start_jobs("fw_update", [device_id])}
|
|
|
|
|
|
@router.post("/batch/{action}", status_code=202)
|
|
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
|
|
"""Групповая операция над списком устройств."""
|
|
return {"job_ids": jobs.start_jobs(action, body.resolve())}
|
|
|
|
|
|
@router.put("/batch/channel", response_model=list[DeviceOut])
|
|
async def batch_channel(body: BatchChannelIn):
|
|
ids = body.resolve()
|
|
for i in ids:
|
|
devices.get_device(i)
|
|
for i in ids:
|
|
await ops.set_channel(i, body.channel)
|
|
return [DeviceOut.of(devices.get_device(i)) for i in ids]
|
|
|
|
|
|
# --- резервные копии в S3 ---
|
|
|
|
@router.get("/backups")
|
|
async def list_backups(
|
|
device_id: int | None = None,
|
|
group: str = "", # "" — все, "none" — без группы, иначе id группы
|
|
kind: Literal["", "backup", "rsc"] = "",
|
|
date_from: date | None = None,
|
|
date_to: date | None = None,
|
|
q: str = "",
|
|
):
|
|
name = devices.get_device(device_id).name if device_id else ""
|
|
return await backups.list_backups(name, group, kind, date_from, date_to, q)
|
|
|
|
|
|
@router.get("/backups/download")
|
|
async def download_backup(key: str):
|
|
if not s3.key_allowed(key):
|
|
raise ValueError("Недопустимый ключ")
|
|
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):
|
|
raise ValueError("Недопустимый ключ")
|
|
await s3.delete_object(key)
|
|
|
|
|
|
# --- задачи ---
|
|
|
|
@router.get("/jobs", response_model=list[JobOut])
|
|
async def list_jobs():
|
|
return jobs.list_jobs()
|
|
|
|
|
|
@router.get("/jobs/{job_id}", response_model=JobOut)
|
|
async def get_job(job_id: int):
|
|
return jobs.get_job(job_id)
|