Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
перезагрузка; API POST /devices/{id}/firmware/downgrade и
/batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.
Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
694 lines
36 KiB
Python
694 lines
36 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
|
|
templates.env.globals["fw_state"] = devices.fw_state # изменение 027
|
|
|
|
|
|
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=""),
|
|
kind: str = Form(default="ros")):
|
|
"""Откат ROS/FW для выбранных устройств (окно «Откатить…», параметризовано kind — изменение 027: без kind
|
|
поведение и адреса как раньше): из выбранных берутся только те, у кого в кэше статуса соответствующее
|
|
состояние downgrade и целевая версия совпадает с введённой; иначе окно остаётся открытым с ошибкой —
|
|
задачи не создаются."""
|
|
if kind not in DOWNGRADE_KINDS:
|
|
raise ValueError(f"Неизвестный вид отката: {kind}")
|
|
k = DOWNGRADE_KINDS[kind]
|
|
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 k["state"](d.status) == "downgrade" and d.status.get(k["target_key"]) == target_version]
|
|
if not matched:
|
|
return _downgrade_dialog(request, devs, k["mismatch_error"], kind)
|
|
await jobs.start_jobs(k["job_type"], [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 до версии канала / откат FW до версии, встроенной в ROS (изменение 027) ---
|
|
|
|
# Описания видов отката для общего окна _dialog_downgrade.html: функция состояния, ключи установленной/целевой
|
|
# версии в кэше статуса, тип фоновой задачи и тексты. Без kind (?kind= отсутствует, поле формы по умолчанию)
|
|
# используется "ros" — адреса и поведение отката ROS не меняются.
|
|
DOWNGRADE_KINDS: dict[str, dict] = {
|
|
"ros": dict(
|
|
title="Откатить ROS до версии канала",
|
|
state=devices.ros_state,
|
|
installed_key="ros_installed",
|
|
target_key="ros_latest",
|
|
col_label="Установлено → канал",
|
|
extra_label="Канал",
|
|
extra_key="channel",
|
|
job_type="ros_downgrade",
|
|
hint="должна совпадать с версией канала выбранных устройств",
|
|
warn=("Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки "
|
|
"устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. "
|
|
"Если версия канала у выбранных устройств различается, откатятся только те, чья версия канала "
|
|
"совпадает с введённой ниже — остальные останутся без изменений."),
|
|
mismatch_reason="версия канала не старше установленной",
|
|
mismatch_error="Версия не совпадает с версией канала выбранных устройств",
|
|
),
|
|
"fw": dict(
|
|
title="Откатить прошивку до версии ROS",
|
|
state=devices.fw_state,
|
|
installed_key="fw_current",
|
|
target_key="fw_upgrade",
|
|
col_label="Записано → в ROS",
|
|
extra_label=None,
|
|
extra_key=None,
|
|
job_type="fw_downgrade",
|
|
hint="должна совпадать с версией, встроенной в установленную ROS выбранных устройств",
|
|
warn=("Перед записью создаётся резервная копия устройства; без неё откат не выполняется. После записи "
|
|
"устройство перезагрузится. Прошивка берётся из установленной ROS — не с серверов MikroTik. Если "
|
|
"встроенная версия у выбранных устройств различается, откатятся только те, чья версия совпадает "
|
|
"с введённой ниже — остальные останутся без изменений."),
|
|
mismatch_reason="версия в ROS не старше записанной",
|
|
mismatch_error="Версия не совпадает с версией в ROS выбранных устройств",
|
|
),
|
|
}
|
|
|
|
|
|
def _downgrade_split(devs: list, kind: str = "ros") -> tuple[list, list]:
|
|
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
|
k = DOWNGRADE_KINDS[kind]
|
|
affected, skipped = [], []
|
|
for d in devs:
|
|
st = k["state"](d.status)
|
|
if st == "downgrade":
|
|
affected.append(d)
|
|
else:
|
|
reason = "нет данных проверки" if st in ("unknown", "check_error") else k["mismatch_reason"]
|
|
skipped.append((d, reason))
|
|
return affected, skipped
|
|
|
|
|
|
def _downgrade_dialog(request: Request, devs: list, error: str | None = None, kind: str = "ros"):
|
|
affected, skipped = _downgrade_split(devs, kind)
|
|
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
|
|
skipped=skipped, error=error, kind=kind, k=DOWNGRADE_KINDS[kind])
|
|
|
|
|
|
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
|
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[]), kind: str = "ros"):
|
|
if kind not in DOWNGRADE_KINDS:
|
|
raise ValueError(f"Неизвестный вид отката: {kind}")
|
|
wanted = set(device_ids)
|
|
devs = [d for d in devices.list_devices() if d.id in wanted]
|
|
return _downgrade_dialog(request, devs, kind=kind)
|
|
|
|
|
|
# --- группы ---
|
|
|
|
@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")
|