Control Server (FastAPI) с WEB UI (Jinja2 + HTMX) и JSON API для группового администрирования устройств RouterOS 7 через REST. - Устройства: список, статус (модель, канал, версии ROS/FW, uptime, доступные обновления), примечания, пароли шифруются (Fernet); фоновый опрос каждые 30 с и быстрое обнаружение недоступности (таймаут соединения 4 с). - Группы устройств и фильтры (устройства, резервные копии). - Резервные копии: .backup и .rsc (show-sensitive) создаются через REST, скачиваются сервером и загружаются в S3 (Yandex Object Storage). - Обновление ROS/FW и выбор канала, групповые операции задачами. - Интерфейс по утверждённому макету: светлая/тёмная темы, окна, шрифты IBM Plex локально. Docker Compose, SQLite в томе, минимальный набор тестов. - Планы и итоги каждого изменения — в docs/changes/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
259 lines
7.8 KiB
Python
259 lines
7.8 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))
|
|
|
|
|
|
@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)
|