Files
ros_control/app/ui/routes.py
T
ayurishchevandClaude Opus 5.5 5367cb5e9c Доверенные прокси (реальный IP клиента) и параметризация порта
Пункты 14 и 16 ревью 2026-09-28 17:35 (docs/changes/025):
- TRUSTED_PROXIES (CIDR, по умолчанию пусто): X-Forwarded-For учитывается
  только от доверенного peer, цепочка разбирается справа налево; иначе
  заголовок игнорируется — подделать IP нельзя. Реальный IP — в блокировке
  входа и событиях auth.*; неверный CIDR — отказ старта;
- docker-compose: "${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"; стенд на
  8001 через APP_PORT в .env, override-файл больше не нужен.

Тесты: 36 из 36. Стенд: поддельный X-Forwarded-For проигнорирован,
заблокирован реальный адрес; боевые данные не изменены. Ручная проверка
UI пользователем на момент коммита не подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:19:10 +03:00

644 lines
32 KiB
Python

"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API."""
import asyncio
import json
from datetime import date
from pathlib import Path
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, Depends, Form, Query, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app import ids, s3, security
from app.config import get_settings
from app.ros.operations import CHANNELS, version_newer
from app.services import backups, devices, events, groups, jobs, ops, settings
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.filters["short_id"] = ids.short_id
templates.env.filters["num"] = lambda n: f"{int(n):,}".replace(",", "\u00a0")
templates.env.filters["dts"] = lambda v: v.strftime("%Y-%m-%d %H:%M:%S") if v else "—"
# смысловая окраска типов событий (как в утверждённом макете)
_EV_KIND = {"bad": ("device.offline", "job.failed", "auth.failed", "auth.locked", "backup.failed", "journal.clear_denied",
"journal.clear_locked"),
"ok": ("device.online", "job.done", "backup.done", "auth.login"),
"run": ("job.started",),
"warn": ("backup.deleted", "device.deleted", "group.deleted", "system.migrated", "journal.cleared", "journal.rotated")}
_EV_KIND_BY_TYPE = {t: k for k, ts in _EV_KIND.items() for t in ts}
_EV_ENTITY = {"device": "Устройство", "group": "Группа", "job": "Задача", "backup": "Копия", "session": "Сессия",
"system": "Система", "journal": "Журнал"}
templates.env.globals["ev_kind"] = lambda t: _EV_KIND_BY_TYPE.get(t, "plain")
templates.env.globals["ev_entity"] = lambda t: _EV_ENTITY.get(t, t)
templates.env.globals["ICONS"] = ICONS
templates.env.globals["ros_newer"] = version_newer
templates.env.globals["ros_state"] = devices.ros_state
class LoginRequired(Exception):
pass
async def require_login(request: Request) -> None:
user = request.session.get("user")
if not user:
raise LoginRequired()
events.set_actor(f"ui:{user}") # async: ContextVar остаётся в контексте обработчика запроса
router = APIRouter(include_in_schema=False)
def _render(request: Request, name: str, status_code: int = 200, **ctx):
return templates.TemplateResponse(request, name, ctx, status_code=status_code)
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_id(value: str, prefix: str) -> str | None:
"""ID нужного типа или None («без группы», пустое значение, чужой тип, мусор)."""
value = str(value).strip()
return value if ids.is_id(value, prefix) 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] = ""
if flt["group"] not in ("", "none") and not ids.is_id(flt["group"], "grp"):
flt["group"] = ""
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)
def login_form(request: Request):
return _render(request, "login.html", error=None)
@router.post("/login")
def login(request: Request, username: str = Form(), password: str = Form()):
"""Вход в UI: попытки ограничены по IP клиента (не по имени — иначе одного администратора
можно заблокировать чужими неверными попытками). При блокировке пароль не проверяется."""
ip = security.client_ip(request)
key = f"login:{ip}"
events.set_actor("anonymous")
lock = security.lockout_remaining(key)
if lock:
# событие auth.locked уже записано в момент начала блокировки (ветка left == 0 ниже);
# повторные попытки во время блокировки в журнал не пишем — иначе перебор засыпает его и вытесняет историю ротацией
minutes = max(1, -(-lock // 60))
return _render(request, "login.html", status_code=429,
error=f"Слишком много попыток входа. Повторите через {minutes} мин.")
if not security.check_admin(username, password):
left = security.register_failure(key)
if left == 0:
events.record("auth.locked", "session", None, "Вход заблокирован: превышено число попыток входа",
data={"ip": ip, "user": username[:64]})
else:
events.record("auth.failed", "session", None, "Неудачная попытка входа в UI",
data={"ip": ip, "user": username[:64], "attempts_left": left})
return _render(request, "login.html", status_code=401, error="Неверный логин или пароль")
security.reset_failures(key)
request.session["user"] = username
events.set_actor(f"ui:{username}")
events.record("auth.login", "session", None, f"Вход в UI: {username}", data={"user": username, "ip": ip})
return RedirectResponse("/", status_code=303)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# --- дашборд ---
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_login)])
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)])
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)])
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[str] = Form(default=[])):
flt = _flt(await request.form())
# без выбора обновляем все устройства текущего представления (группа + фильтры)
await ops.refresh_many(device_ids or [d.id for d in await asyncio.to_thread(_visible, flt)])
return _render(request, "_devices.html", oob=True, **await asyncio.to_thread(_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[str] = Form(default=[]),
channel: str = Form(default="")):
if not device_ids:
return _render(request, "_jobs.html", jobs=await asyncio.to_thread(jobs.list_jobs, 15), flash="Выберите устройства")
if action == "channel":
if channel not in CHANNELS:
raise ValueError(f"Неизвестный канал: {channel}")
await jobs.start_jobs("set_channel", device_ids, {"channel": channel})
else:
await jobs.start_jobs(action, device_ids)
return _render(request, "_jobs.html", jobs=await asyncio.to_thread(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: str, 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:
await jobs.start_jobs(action, [device_id])
return _render(request, "_jobs.html", jobs=await asyncio.to_thread(jobs.list_jobs, 15))
flt = _flt(await request.form())
return _render(request, "_devices.html", oob=True, **await asyncio.to_thread(_devices_ctx, flt))
@router.post("/ui/downgrade", dependencies=[Depends(require_login)])
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default="")):
"""Откат ROS для выбранных устройств (окно «Откатить ROS…»): из выбранных берутся только те, у кого
в кэше статуса ros_state == downgrade и версия канала совпадает с введённой; иначе окно остаётся
открытым с ошибкой — задачи не создаются."""
target_version = target_version.strip()
wanted = set(device_ids)
def _load():
return [d for d in devices.list_devices() if d.id in wanted]
devs = await asyncio.to_thread(_load)
matched = [d for d in devs if devices.ros_state(d.status) == "downgrade" and d.status.get("ros_latest") == target_version]
if not matched:
return _downgrade_dialog(request, devs, "Версия не совпадает с версией канала выбранных устройств")
await jobs.start_jobs("ros_downgrade", [d.id for d in matched], {"target_version": target_version})
return _done(request)
def _safe_next(value: str, default: str = "/", prefix: str = "/") -> str:
"""Адрес для редиректа после формы, присланный пользователем (`next`/`back`): только локальный путь,
иначе открытый редирект (в т.ч. `\\evil.com`, который браузеры трактуют как `//evil.com`)."""
value = value or ""
if (not value.startswith(prefix) or value.startswith("//")
or any(ord(ch) < 0x20 or ch in ("\\", "\x7f") for ch in value)):
return default
parts = urlsplit(value)
return value if not parts.scheme and not parts.netloc else default
@router.post("/ui/move", dependencies=[Depends(require_login)])
def move(device_ids: list[str] = Form(default=[]), group_id: str = Form(""), next: str = Form("/")):
"""Перенос отмеченных устройств в группу (обычный POST + редирект: счётчики вкладок обновляются)."""
if device_ids:
groups.move_devices(device_ids, _opt_id(group_id, "grp"))
return RedirectResponse(_safe_next(next), 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)])
def dialog_device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if ids.is_id(group, "grp") 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)])
def dialog_device_edit(request: Request, device_id: str):
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)])
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)])
def dialog_group_edit(request: Request, group_id: str):
g = groups.get_group(group_id)
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
# --- откат ROS до версии канала ---
def _downgrade_split(devs: list) -> tuple[list, list]:
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
affected, skipped = [], []
for d in devs:
rst = devices.ros_state(d.status)
if rst == "downgrade":
affected.append(d)
else:
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
skipped.append((d, reason))
return affected, skipped
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
affected, skipped = _downgrade_split(devs)
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
skipped=skipped, error=error)
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])):
wanted = set(device_ids)
devs = [d for d in devices.list_devices() if d.id in wanted]
return _downgrade_dialog(request, devs)
# --- группы ---
@router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
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)])
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)])
def group_rename(request: Request, group_id: str, 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)])
def group_delete(request: Request, group_id: str):
return _group_action(request, groups.delete_group, group_id)
# --- управление списком устройств ---
def _group_choice(group_id: str, new_group: str) -> tuple[str | None, str | None]:
"""Выбор в списке «Группа»: id, «без группы» или «Новая группа…» (тогда нужно название)."""
if group_id == "__new__":
if not new_group.strip():
raise ValueError("Введите название новой группы")
return None, new_group.strip()
return _opt_id(group_id, "grp"), None
@router.get("/devices/new", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if ids.is_id(group, "grp") 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)
def _create():
gid, ng = _group_choice(group_id, new_group)
return devices.create_device(name, host, port, username, password, verify_tls, use_tls, gid, ng, note)
try:
d = await asyncio.to_thread(_create)
except ValueError as e:
return await asyncio.to_thread(_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)])
def device_edit(request: Request, device_id: str):
d = devices.get_device(device_id)
return _device_form(request, d, _device_values(d))
@router.post("/devices/{device_id}/edit", dependencies=[Depends(require_login)])
def device_update(request: Request, device_id: str, 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)])
def device_delete(request: Request, device_id: str):
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,
refresh: bool = False):
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, refresh=refresh)
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)
non_empty = {k: v for k, v in flt.items() if v}
def _lists():
return devices.list_devices(), groups.list_groups()
dev_list, grp_list = await asyncio.to_thread(_lists)
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(non_empty) if non_empty else ""),
refresh_url="/backups?" + urlencode({**non_empty, "refresh": "1"}),
deleted=deleted, failed=failed,
devices=dev_list, groups=grp_list)
@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 = _safe_next(next, default="/backups", prefix="/backups")
return RedirectResponse(back + ("&" if "?" in back else "?") + urlencode({"deleted": deleted, "failed": failed}), status_code=303)
@router.get("/backups/download", dependencies=[Depends(require_login)])
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()):
"""Одиночное удаление — тот же сервис, что и групповое: метаданные и событие backup.deleted не теряются."""
deleted, failed = await backups.delete_many([key])
return RedirectResponse(f"/backups?deleted={deleted}&failed={failed}", status_code=303)
# --- журнал событий ---
EVENTS_PAGE = 100
EV_ACTORS = ("ui", "api", "poller", "system", "anonymous")
def _ev_filters(params) -> dict:
p = lambda k: str(params.get(k) or "").strip() # noqa: E731
f = dict(type=p("type"), actor=p("actor"), device=p("device"), date_from=p("date_from"), date_to=p("date_to"), q=p("q"))
if f["device"] and not ids.is_id(f["device"], "dev"):
f["device"] = ""
if f["actor"] not in EV_ACTORS:
f["actor"] = ""
return f
def _ev_query(f: dict) -> dict:
return dict(type_=f["type"] or None, actor=f["actor"] or None, device_id=f["device"] or None,
date_from=_opt_date(f["date_from"]), date_to=_opt_date(f["date_to"]), text=f["q"] or None)
def _ev_page(f: dict, before: str | None = None, shown: int = 0) -> dict:
query = _ev_query(f)
rows = events.list_events(before=before, limit=EVENTS_PAGE + 1, **query)
more = len(rows) > EVENTS_PAGE
rows = rows[:EVENTS_PAGE]
return dict(rows=rows, more=more, next_before=rows[-1].id if rows else None, total=events.count(**query),
shown=shown + len(rows), qs=urlencode({k: v for k, v in f.items() if v}))
def _rotation_text(cfg: dict) -> str:
days, rows = cfg["retention_days"], cfg["max_rows"]
if not days and not rows:
return "без ограничений"
d = f"{days} {plural(days, 'день', 'дня', 'дней')}" if days else "без ограничения по сроку"
r = f"{rows:,}".replace(",", "\u00a0") + " записей" if rows else "без ограничения по числу"
return f"{d} / {r}"
@router.get("/events", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def events_page(request: Request):
f = _ev_filters(request.query_params)
st = events.stats()
all_types = events.types()
return _render(request, "events.html", section="events", flt=f, flt_active=sum(1 for v in f.values() if v),
st=st, cfg=settings.journal(), rotation=_rotation_text(settings.journal()), oob=False,
categories=sorted({t.split(".")[0] for t in all_types}), types=all_types, actors=EV_ACTORS,
devices=devices.list_devices(), **_ev_page(f))
@router.get("/ui/events", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def events_more(request: Request, before: str = "", shown: int = 0):
"""Следующие записи журнала (кнопка «Показать ещё»): строки + обновлённая кнопка и подвал (OOB)."""
f = _ev_filters(request.query_params)
return _render(request, "_events_chunk.html", oob=True, **_ev_page(f, before=before or None, shown=shown))
def _event_ctx(event_id: str) -> dict:
ids.check(event_id, "evt")
e = events.get_event(event_id)
device = None
if e.device_id:
try:
device = devices.get_device(e.device_id)
except LookupError:
device = None
data = json.dumps(json.loads(e.data), ensure_ascii=False, indent=2) if e.data else ""
return dict(e=e, device=device, data_json=data)
@router.get("/ui/dialog/event/{event_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_event(request: Request, event_id: str):
return _render(request, "_event_dialog.html", in_modal=True, **_event_ctx(event_id))
@router.get("/events/{event_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def event_page(request: Request, event_id: str):
return _render(request, "event_page.html", section="events", in_modal=False, **_event_ctx(event_id))
def _settings_dialog(request: Request, error: str | None = None, values: dict | None = None):
return _render(request, "_events_settings.html", in_modal=_is_htmx(request), error=error,
values=values or settings.journal(), st=events.stats())
@router.get("/ui/dialog/events-settings", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_events_settings(request: Request):
return _settings_dialog(request)
@router.post("/events/settings", dependencies=[Depends(require_login)])
def events_settings_save(request: Request, retention_days: str = Form(""), max_rows: str = Form("")):
try:
settings.save_journal(retention_days, max_rows)
except ValueError as e:
return _settings_dialog(request, str(e), {"retention_days": retention_days, "max_rows": max_rows})
return _done(request, "/events")
def _clear_dialog(request: Request, error: str | None = None):
user = request.session.get("user", "")
lock = security.lockout_remaining(user)
return _render(request, "_events_clear.html", in_modal=_is_htmx(request), error=error, user=user,
locked=lock > 0, minutes=max(1, -(-lock // 60)), st=events.stats())
@router.get("/ui/dialog/events-clear", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_events_clear(request: Request):
return _clear_dialog(request)
@router.post("/events/clear", dependencies=[Depends(require_login)])
def events_clear(request: Request, password: str = Form("")):
"""Очистка журнала — только через окно подтверждения паролем пользователя сессии (через API её нет)."""
if not _is_htmx(request):
raise ValueError("Очистка журнала выполняется только через окно подтверждения")
user = request.session["user"]
if security.lockout_remaining(user):
return _clear_dialog(request)
if not password:
return _clear_dialog(request, "Введите пароль")
if not security.verify_password(user, password):
left = security.register_failure(user)
events.record("journal.clear_denied", "journal", None, "Неверный пароль при очистке журнала", data={"attempts_left": left})
if left == 0:
events.record("journal.clear_locked", "journal", None, "Очистка журнала заблокирована после неверных попыток",
data={"lock_seconds": security.LOCK_S})
return _clear_dialog(request)
return _clear_dialog(request, f"Неверный пароль. Осталось попыток: {left}")
security.reset_failures(user)
events.clear(user)
return _done(request, "/events")