Имя входит в ключи бэкапов в S3 (backups/<имя>/…), поэтому после создания не меняется (docs/changes/015): - API: PATCH с другим именем → 400 «Имя устройства нельзя изменить», то же имя допустимо; - UI: в окне изменения поле имени только для чтения, форма изменения присланное имя игнорирует; - вёрстка: минимальная ширина таблиц устройств и файлов — в узком окне колонка с именем не схлопывается, включается горизонтальная прокрутка. Тесты: 12 из 12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
393 lines
18 KiB
Python
393 lines
18 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, version_newer
|
|
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
|
|
templates.env.globals["ros_newer"] = version_newer
|
|
|
|
|
|
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, 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("")):
|
|
"""Имя устройства при изменении не принимается: оно задаётся только при создании."""
|
|
current = devices.get_device(device_id)
|
|
v = dict(name=current.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, 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, current, 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 = "", deleted: int = -1, failed: int = 0):
|
|
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,
|
|
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):
|
|
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)
|