Files
ros_control/app/ui/routes.py
T
ayurishchevandClaude Sonnet 5 4c1841b61b ros_control: централизованное управление парком MikroTik RouterOS
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>
2026-09-19 13:13:29 +03:00

380 lines
17 KiB
Python

"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API."""
from datetime import date
from pathlib import Path
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app import s3, security
from app.config import get_settings
from app.ros.operations import CHANNELS
from app.services import backups, devices, groups, jobs, ops
from app.ui.icons import ICONS
def plural(n, one: str, few: str, many: str) -> str:
"""Русское склонение по числу: 1 файл, 2 файла, 5 файлов."""
n = abs(int(n))
if n % 10 == 1 and n % 100 != 11:
return one
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return few
return many
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
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
class LoginRequired(Exception):
pass
def require_login(request: Request) -> None:
if not request.session.get("user"):
raise LoginRequired()
router = APIRouter(include_in_schema=False)
def _render(request: Request, name: str, **ctx):
return templates.TemplateResponse(request, name, ctx)
def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
def _done(request: Request, fallback: str = "/"):
"""Успех формы: из окна (HTMX) — обновить страницу, обычной формой — редирект."""
if _is_htmx(request):
return Response(headers={"HX-Refresh": "true"})
return RedirectResponse(fallback, status_code=303)
def _opt_int(value: str) -> int | None:
return int(value) if str(value).strip().isdigit() else None
FILTER_FIELDS = ("group", "q", "status", "updates", "channel") # в форме и URL — с префиксом f_
FILTER_VALUES = {"status": ("online", "offline"), "updates": ("ros", "fw", "any", "none")}
def _flt(params) -> dict:
"""Фильтр списка устройств из query/формы (поля f_*)."""
flt = {k: str(params.get(f"f_{k}") or "").strip() for k in FILTER_FIELDS}
for k, allowed in FILTER_VALUES.items():
if flt[k] not in allowed:
flt[k] = ""
return flt
def _page_url(flt: dict, **override) -> str:
"""Адрес страницы устройств с фильтром (только непустые поля)."""
merged = {**flt, **override}
query = urlencode({f"f_{k}": v for k, v in merged.items() if v})
return "/?" + query if query else "/"
def _visible(flt: dict):
return devices.filter_devices(devices.list_devices(), **flt)
def _devices_ctx(flt: dict) -> dict:
everyone = devices.list_devices()
stats = {"total": len(everyone), "online": sum(d.online is True for d in everyone),
"offline": sum(d.online is False for d in everyone)}
return {"devices": devices.filter_devices(everyone, **flt), "channels": CHANNELS, "filtered": any(flt.values()),
"total": len(everyone), "stats": stats,
"group_names": {g["id"]: g["name"] for g in groups.list_groups()}}
def _tabs(flt: dict) -> list[dict]:
"""Вкладки групп: «Все», «Без группы» и каждая группа со счётчиком устройств."""
all_devices = devices.list_devices()
glist = groups.list_groups()
items = [("Все", "", len(all_devices)),
("Без группы", "none", sum(1 for d in all_devices if d.group_id is None))]
items += [(g["name"], str(g["id"]), g["device_count"]) for g in glist]
return [{"label": label, "count": n, "href": _page_url(flt, group=value), "active": flt["group"] == value}
for label, value, n in items]
# --- вход / выход ---
@router.get("/login", response_class=HTMLResponse)
async def login_form(request: Request):
return _render(request, "login.html", error=None)
@router.post("/login")
async def login(request: Request, username: str = Form(), password: str = Form()):
if not security.check_admin(username, password):
return _render(request, "login.html", error="Неверный логин или пароль")
request.session["user"] = username
return RedirectResponse("/", status_code=303)
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# --- дашборд ---
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dashboard(request: Request):
flt = _flt(request.query_params)
return _render(request, "dashboard.html", section="devices", jobs=jobs.list_jobs(15), flt=flt, tabs=_tabs(flt),
groups=groups.list_groups(), page_url=_page_url(flt),
flt_active=sum(1 for k in ("q", "status", "updates", "channel") if flt[k]), **_devices_ctx(flt))
@router.get("/ui/devices", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def devices_table(request: Request):
flt = _flt(request.query_params)
resp = _render(request, "_devices.html", oob=True, **_devices_ctx(flt))
if request.query_params.get("poll") != "1": # автообновление не должно засорять историю браузера
resp.headers["HX-Push-Url"] = _page_url(flt) # фильтр переживает перезагрузку страницы
return resp
@router.get("/ui/jobs", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def jobs_table(request: Request):
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
@router.post("/ui/refresh", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def refresh(request: Request, device_ids: list[int] = Form(default=[])):
flt = _flt(await request.form())
# без выбора обновляем все устройства текущего представления (группа + фильтры)
await ops.refresh_many(device_ids or [d.id for d in _visible(flt)])
return _render(request, "_devices.html", oob=True, **_devices_ctx(flt))
@router.post("/ui/batch/{action}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def batch(request: Request, action: str, device_ids: list[int] = Form(default=[]),
channel: str = Form(default="")):
if not device_ids:
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15), flash="Выберите устройства")
if action == "channel":
for i in device_ids:
await ops.set_channel(i, channel)
return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form())))
jobs.start_jobs(action, device_ids)
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
@router.post("/ui/devices/{device_id}/{action}", response_class=HTMLResponse,
dependencies=[Depends(require_login)])
async def device_action(request: Request, device_id: int, action: str, channel: str = Form(default="")):
"""Действие над одним устройством (пункты меню «⋯» в строке таблицы)."""
if action == "refresh":
await ops.refresh_status(device_id)
elif action == "channel":
await ops.set_channel(device_id, channel)
else:
jobs.start_jobs(action, [device_id])
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form())))
@router.post("/ui/move", dependencies=[Depends(require_login)])
async def move(device_ids: list[int] = Form(default=[]), group_id: str = Form(""), next: str = Form("/")):
"""Перенос отмеченных устройств в группу (обычный POST + редирект: счётчики вкладок обновляются)."""
if device_ids:
groups.move_devices(device_ids, _opt_int(group_id))
return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/", status_code=303)
# --- окна (dialog) и формы: одна разметка для окна и запасной страницы ---
def _device_values(device=None, **form) -> dict:
"""Значения полей формы устройства: из отправленной формы (при ошибке), из устройства или значения по умолчанию."""
if form:
return form
if device is not None:
return dict(name=device.name, host=device.host, port=device.port, username=device.username,
group_id=str(device.group_id or ""), new_group="", use_tls=device.use_tls,
verify_tls=device.verify_tls, note=device.note or "")
return dict(name="", host="", port=443, username="", group_id="", new_group="", use_tls=True, verify_tls=False, note="")
def _device_form(request: Request, device, v: dict, error: str | None = None):
ctx = dict(device=device, v=v, error=error, groups=groups.list_groups(), section="devices")
if _is_htmx(request):
return _render(request, "_device_form.html", in_modal=True, **ctx)
return _render(request, "device_form.html", in_modal=False, **ctx)
def _group_form(request: Request, group, name: str, error: str | None = None):
ctx = dict(group=group, gname=name, error=error)
return _render(request, "_group_form.html", in_modal=_is_htmx(request), **ctx)
@router.get("/ui/dialog/device", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dialog_device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if group.isdigit() else ""
return _render(request, "_device_form.html", in_modal=True, device=None, v=v, error=None, groups=groups.list_groups())
@router.get("/ui/dialog/device/{device_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dialog_device_edit(request: Request, device_id: int):
d = devices.get_device(device_id)
return _render(request, "_device_form.html", in_modal=True, device=d, v=_device_values(d), error=None,
groups=groups.list_groups())
@router.get("/ui/dialog/group", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dialog_group_new(request: Request):
return _render(request, "_group_form.html", in_modal=True, group=None, gname="", error=None)
@router.get("/ui/dialog/group/{group_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dialog_group_edit(request: Request, group_id: int):
g = groups.get_group(group_id)
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
# --- группы ---
@router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def groups_page(request: Request, error: str = ""):
everyone = devices.list_devices()
return _render(request, "groups.html", section="groups", groups=groups.list_groups(), error=error,
total=len(everyone), ungrouped=sum(1 for d in everyone if d.group_id is None))
def _group_action(request: Request, fn, *args, name: str = "", group=None):
try:
fn(*args)
except ValueError as e:
if _is_htmx(request): # ошибка показывается в окне
return _group_form(request, group, name, str(e))
return RedirectResponse("/groups?" + urlencode({"error": str(e)}), status_code=303)
return _done(request, "/groups")
@router.post("/groups/new", dependencies=[Depends(require_login)])
async def group_create(request: Request, name: str = Form()):
return _group_action(request, groups.create_group, name, name=name)
@router.post("/groups/{group_id}/rename", dependencies=[Depends(require_login)])
async def group_rename(request: Request, group_id: int, name: str = Form()):
return _group_action(request, groups.rename_group, group_id, name, name=name, group=groups.get_group(group_id))
@router.post("/groups/{group_id}/delete", dependencies=[Depends(require_login)])
async def group_delete(request: Request, group_id: int):
return _group_action(request, groups.delete_group, group_id)
# --- управление списком устройств ---
def _group_choice(group_id: str, new_group: str) -> tuple[int | None, str | None]:
"""Выбор в списке «Группа»: id, «без группы» или «Новая группа…» (тогда нужно название)."""
if group_id == "__new__":
if not new_group.strip():
raise ValueError("Введите название новой группы")
return None, new_group.strip()
return _opt_int(group_id), None
@router.get("/devices/new", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if group.isdigit() else ""
return _device_form(request, None, v)
@router.post("/devices/new", dependencies=[Depends(require_login)])
async def device_create(request: Request, name: str = Form(), host: str = Form(),
port: int = Form(443), username: str = Form(), password: str = Form(),
verify_tls: bool = Form(False), use_tls: bool = Form(False),
group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")):
v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
use_tls=use_tls, verify_tls=verify_tls, note=note)
try:
gid, ng = _group_choice(group_id, new_group)
d = devices.create_device(name, host, port, username, password, verify_tls, use_tls, gid, ng, note)
except ValueError as e:
return _device_form(request, None, v, str(e))
await ops.refresh_status(d.id)
return _done(request)
@router.get("/devices/{device_id}/edit", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def device_edit(request: Request, device_id: int):
d = devices.get_device(device_id)
return _device_form(request, d, _device_values(d))
@router.post("/devices/{device_id}/edit", dependencies=[Depends(require_login)])
async def device_update(request: Request, device_id: int, name: str = Form(), host: str = Form(),
port: int = Form(443), username: str = Form(), password: str = Form(""),
verify_tls: bool = Form(False), use_tls: bool = Form(False),
group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")):
v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
use_tls=use_tls, verify_tls=verify_tls, note=note)
try:
gid, ng = _group_choice(group_id, new_group)
devices.update_device(device_id, name=name, host=host, port=port, username=username, password=password,
verify_tls=verify_tls, use_tls=use_tls, group_id=gid, new_group=ng, note=note)
except ValueError as e:
return _device_form(request, devices.get_device(device_id), v, str(e))
return _done(request)
@router.post("/devices/{device_id}/delete", dependencies=[Depends(require_login)])
async def device_delete(request: Request, device_id: int):
devices.delete_device(device_id)
return _done(request)
# --- резервные копии в бакете ---
def _opt_date(value: str) -> date | None:
try:
return date.fromisoformat(value)
except ValueError:
return 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 = ""):
kind = kind if kind in ("backup", "rsc") else ""
items, stats, error = [], {"total": 0, "size": 0}, None
try:
items, stats = await backups.search(device, group, kind, _opt_date(date_from), _opt_date(date_to), q)
except Exception as e: # noqa: BLE001 — показываем ошибку S3 на странице
error = str(e)
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,
devices=devices.list_devices(), groups=groups.list_groups())
@router.get("/backups/download", dependencies=[Depends(require_login)])
async def backup_download(key: str):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
return RedirectResponse(s3.presign_get(key))
@router.post("/backups/delete", dependencies=[Depends(require_login)])
async def backup_delete(key: str = Form()):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
await s3.delete_object(key)
return RedirectResponse("/backups", status_code=303)