Уникальные ID сущностей, журнал событий и его UI с ротацией и очисткой

Глобально уникальные ID (docs/changes/016):
- у устройств, групп, задач, резервных копий и записей журнала ID вида
  <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются,
  внутри типа ID не повторяются и сортируются по времени;
- миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД
  перед миграцией, сверка числа строк, одна транзакция);
- резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают
  метаданные backup-id/device-id; синхронизация метаданных с бакетом;
- журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы,
  смена online/offline, вход в UI; API чтения GET /api/v1/events.

Журнал в UI, ротация и очистка (docs/changes/017):
- страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи;
- настройки ротации (срок и максимум записей) хранятся в БД (схема v2),
  ротация при старте, раз в час и после сохранения настроек;
- очистка журнала только через окно с паролем пользователя, блокировка
  после 5 неверных попыток, остаётся запись о факте очистки; через API
  очистки нет.

Тесты: 20 из 20.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-19 17:01:52 +03:00
1 parent 1e5fb17755
commit 26dd1f8be4
43 files changed
+1677 -157

No files matched your search

+4
View File
@@ -27,3 +27,7 @@ MAX_CONCURRENCY=10
ROS_CONNECT_TIMEOUT=4 # секунд на установление соединения
POLL_INTERVAL=30 # период фонового опроса, секунд (0 — выключить)
UPDATE_CHECK_INTERVAL=1800 # как часто проверять обновления ROS в опросе, секунд
# Ротация журнала событий: значения по умолчанию (действующие настройки меняются в UI: Журнал → Настройки)
EVENTS_RETENTION_DAYS=90 # хранить записи, дней (0 — без ограничения)
EVENTS_MAX_ROWS=100000 # максимум записей (0 — без ограничения)
+17 -3
View File
@@ -28,6 +28,15 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
- Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»).
- Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API).
## Идентификаторы и журнал событий
У каждой сущности глобально уникальный ID вида `<префикс>_<uuid7>`: `dev_…` устройство, `grp_…` группа, `job_…` задача, `bkp_…` резервная копия, `evt_…` запись журнала. Префикс исключает пересечение типов, UUIDv7 не повторяется и сортируется по времени создания; ID не переиспользуются после удаления. Числовых ID нет: `GET /api/v1/devices/dev_…`, ID чужого типа или неверного формата — 404.
Резервная копия — пара файлов (`.backup` + `.rsc`) с одним `bkp_` ID; файлы в бакете имеют метаданные `backup-id`/`device-id` (Yandex возвращает их как `Backup-Id`/`Device-Id`), в списке файлов API есть `backup_id`; копия, у которой пропали файлы, помечается удалённой, но остаётся в истории.
**Журнал событий** пишется в БД: создание, изменение и удаление устройств и групп, перенос устройств, жизненный цикл задач и бэкапов, смена online/offline, вход в UI, ротация и очистка журнала. Каждая запись содержит `evt_` ID, ID сущности, актора (`ui:<пользователь>`, `api`, `poller`, `system`) и данные.
В UI — страница «Журнал» (фильтры по типу, актору, устройству, периоду и поиску по сообщению/ID; подгрузка «Показать ещё 100»; клик по строке открывает запись с полными ID и данными).
- **Ротация**: «Журнал → Настройки» — срок хранения (дней) и максимум записей, `0` — без ограничения. Настройки хранятся в БД (значения по умолчанию — `EVENTS_RETENTION_DAYS=90`, `EVENTS_MAX_ROWS=100000` в `.env`); ротация выполняется при старте, раз в час и сразу после сохранения настроек и оставляет запись `journal.rotated`.
- **Очистка**: «Журнал → Очистить журнал» открывает окно с вводом пароля пользователя. Неверный пароль журнал не трогает (`journal.clear_denied`), 5 неверных попыток за 10 минут блокируют очистку на 10 минут. После успешной очистки остаётся одна запись `journal.cleared` (кто и сколько удалил). Очистки через API нет — только через это окно; API даёт чтение журнала и настроек.
## Интерфейс
Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными.
@@ -53,7 +62,7 @@ cp .env.example .env # заполнить SECRET_KEY, ADMIN_PASSWORD, API_TOK
docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
```
БД (SQLite) хранится в Docker-томе `ros_data` (`docker volume inspect ros_control_ros_data`), переживает пересборку контейнера; схема обновляется автоматически при старте. Остановка: `docker compose down` (том сохраняется; `down -v` удалит БД).
БД (SQLite) хранится в Docker-томе `ros_data` (`docker volume inspect ros_control_ros_data`), переживает пересборку контейнера; схема обновляется автоматически при старте (перед миграцией ID создаётся копия `ros_control.db.bak-<метка>` рядом с БД). Остановка: `docker compose down` (том сохраняется; `down -v` удалит БД).
Ключ Fernet: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.
@@ -72,6 +81,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
| `ROS_TIMEOUT` | `30` | секунд на ответ устройства (долгие операции) |
| `POLL_INTERVAL` | `30` | период фонового опроса, с; `0` — выключить |
| `UPDATE_CHECK_INTERVAL` | `1800` | как часто опрос проверяет обновления ROS, с |
| `EVENTS_RETENTION_DAYS` / `EVENTS_MAX_ROWS` | `90` / `100000` | ротация журнала по умолчанию (действующие значения меняются в UI); `0` — без ограничения |
### Разработка
@@ -79,7 +89,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
set -a; . ./.env; set +a
./venv/bin/uvicorn app.main:app --reload
./venv/bin/python -m pytest # 12 тестов, фоновый опрос в тестах выключен
./venv/bin/python -m pytest # 20 тестов, фоновый опрос в тестах выключен
```
## API v1
@@ -90,7 +100,7 @@ set -a; . ./.env; set +a
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devices | python3 -m json.tool
```
Устройство: `name`, `host`, `port`, `username`, `password` (только при записи), `verify_tls`, `use_tls` (`false` — HTTP), `group_id` (`null` — без группы), `note`.
Все `id` — строки (`dev_…`, `grp_…`, `job_…`); `group_id` — `grp_…`. Устройство: `name`, `host`, `port`, `username`, `password` (только при записи), `verify_tls`, `use_tls` (`false` — HTTP), `group_id` (`null` — без группы), `note`.
| Метод | Путь | Назначение |
|---|---|---|
@@ -108,6 +118,8 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
| GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`) |
| POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` |
| GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач |
| GET | `/api/v1/events`, `/events/{id}` | журнал событий (фильтры `entity_id`, `type`, `device_id`, `job_id`, `actor`, `date_from`, `date_to`, `q`; постранично `before=<evt_…>`, `limit`) |
| GET/PUT | `/api/v1/events/settings` | настройки ротации журнала `{"retention_days", "max_rows"}` и сводка (очистки журнала через API нет) |
Внешние справочники: [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API#RESTAPI-HTTPMethods),
[Yandex Object Storage S3 API](https://yandex.cloud/ru/docs/storage/s3/api-ref/).
@@ -138,3 +150,5 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
- `013-bulk-delete-backups` — выбор файлов чекбоксами и групповое удаление бэкапов.
- `014-chr-support` — поддержка CHR (нет `/system/routerboard`), строгое сравнение версий ROS, причина недоступности в таблице.
- `015-immutable-device-name` — имя устройства задаётся только при создании.
- `016-unique-ids-and-event-log` — глобально уникальные ID (префикс + UUIDv7) для всех сущностей, журнал событий в БД, миграция числовых ID.
- `017-events-ui-rotation` — страница «Журнал» в UI, настройки ротации, очистка журнала с подтверждением паролем.
+96 -39
View File
@@ -1,14 +1,15 @@
import json
from datetime import date, datetime
from typing import Literal
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app import s3
from app import ids, s3
from app.ros.operations import CHANNELS
from app.security import require_api_token
from app.services import backups, devices, groups, jobs, ops
from app.services import backups, devices, events, groups, jobs, ops, settings
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_token)], tags=["api"])
@@ -21,7 +22,7 @@ class DeviceIn(BaseModel):
password: str
verify_tls: bool = False
use_tls: bool = True # False = HTTP (сервис www, порт 80): пароль идёт открытым текстом
group_id: int | None = None
group_id: str | None = None # grp_…
note: str | None = None
@@ -33,20 +34,20 @@ class DevicePatch(BaseModel):
password: str | None = None
verify_tls: bool | None = None
use_tls: bool | None = None
group_id: int | None = None # явный null — открепить от группы
group_id: str | None = None # grp_…; явный null — открепить от группы
note: str | None = None # пустая строка или null — очистить примечание
class DeviceOut(BaseModel):
"""Пароль устройства в ответы не попадает."""
id: int
id: str # dev_…
name: str
host: str
port: int
username: str
verify_tls: bool
use_tls: bool
group_id: int | None
group_id: str | None
note: str | None
online: bool | None
status: dict
@@ -60,8 +61,8 @@ class DeviceOut(BaseModel):
class JobOut(BaseModel):
id: int
device_id: int | None
id: str # job_…
device_id: str | None
device_name: str
type: str
status: str
@@ -81,25 +82,25 @@ class GroupIn(BaseModel):
class GroupOut(BaseModel):
id: int
id: str # grp_…
name: str
device_count: int = 0
class BatchIn(BaseModel):
"""Устройства, над которыми выполняется операция: список id и/или целая группа."""
device_ids: list[int] = Field(default_factory=list)
group_id: int | None = None
device_ids: list[str] = Field(default_factory=list) # dev_…
group_id: str | None = None # grp_…
def resolve(self) -> list[int]:
ids = list(self.device_ids)
def resolve(self) -> list[str]:
found = list(self.device_ids)
if self.group_id is not None:
groups.get_group(self.group_id) # 404, если группы нет
ids += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
ids = list(dict.fromkeys(ids))
if not ids:
found += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
found = list(dict.fromkeys(found))
if not found:
raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id")
return ids
return found
class BatchChannelIn(BatchIn):
@@ -110,7 +111,7 @@ class BatchChannelIn(BatchIn):
@router.get("/devices", response_model=list[DeviceOut])
async def list_devices(
group: str = "", # "" — все, "none" — без группы, иначе id группы
group: str = "", # "" — все, "none" — без группы, иначе ID группы (grp_…)
q: str = "",
status: Literal["", "online", "offline"] = "",
updates: Literal["", "ros", "fw", "any", "none"] = "",
@@ -134,13 +135,13 @@ async def create_group(body: GroupIn):
@router.patch("/groups/{group_id}", response_model=GroupOut)
async def rename_group(group_id: int, body: GroupIn):
async def rename_group(group_id: str, body: GroupIn):
groups.rename_group(group_id, body.name)
return next(g for g in groups.list_groups() if g["id"] == group_id)
@router.delete("/groups/{group_id}", status_code=204)
async def delete_group(group_id: int):
async def delete_group(group_id: str):
groups.delete_group(group_id)
@@ -150,22 +151,22 @@ async def create_device(body: DeviceIn):
@router.get("/devices/{device_id}", response_model=DeviceOut)
async def get_device(device_id: int):
async def get_device(device_id: str):
return DeviceOut.of(devices.get_device(device_id))
@router.patch("/devices/{device_id}", response_model=DeviceOut)
async def patch_device(device_id: int, body: DevicePatch):
async def patch_device(device_id: str, body: DevicePatch):
return DeviceOut.of(devices.update_device(device_id, **body.model_dump(exclude_unset=True)))
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(device_id: int):
async def delete_device(device_id: str):
devices.delete_device(device_id)
@router.post("/devices/{device_id}/refresh", response_model=DeviceOut)
async def refresh_device(device_id: int):
async def refresh_device(device_id: str):
devices.get_device(device_id)
await ops.refresh_status(device_id)
return DeviceOut.of(devices.get_device(device_id))
@@ -180,24 +181,24 @@ async def refresh_all():
# --- операции (долгие — через jobs) ---
@router.post("/devices/{device_id}/backups", status_code=202)
async def create_backup(device_id: int):
async def create_backup(device_id: str):
return {"job_ids": jobs.start_jobs("backup", [device_id])}
@router.put("/devices/{device_id}/update/channel", response_model=DeviceOut)
async def put_channel(device_id: int, body: ChannelIn):
async def put_channel(device_id: str, body: ChannelIn):
devices.get_device(device_id)
await ops.set_channel(device_id, body.channel)
return DeviceOut.of(devices.get_device(device_id))
@router.post("/devices/{device_id}/update/install", status_code=202)
async def install_update(device_id: int):
async def install_update(device_id: str):
return {"job_ids": jobs.start_jobs("ros_update", [device_id])}
@router.post("/devices/{device_id}/firmware/upgrade", status_code=202)
async def upgrade_firmware(device_id: int):
async def upgrade_firmware(device_id: str):
return {"job_ids": jobs.start_jobs("fw_update", [device_id])}
@@ -209,20 +210,20 @@ async def batch(action: Literal["backup", "ros_update", "fw_update"], body: Batc
@router.put("/batch/channel", response_model=list[DeviceOut])
async def batch_channel(body: BatchChannelIn):
ids = body.resolve()
for i in ids:
targets = body.resolve()
for i in targets:
devices.get_device(i)
for i in ids:
for i in targets:
await ops.set_channel(i, body.channel)
return [DeviceOut.of(devices.get_device(i)) for i in ids]
return [DeviceOut.of(devices.get_device(i)) for i in targets]
# --- резервные копии в S3 ---
@router.get("/backups")
async def list_backups(
device_id: int | None = None,
group: str = "", # "" — все, "none" — без группы, иначе id группы
device_id: str | None = None,
group: str = "", # "" — все, "none" — без группы, иначе ID группы (grp_…)
kind: Literal["", "backup", "rsc"] = "",
date_from: date | None = None,
date_to: date | None = None,
@@ -252,9 +253,65 @@ async def delete_backups(body: KeysIn):
@router.delete("/backups", status_code=204)
async def delete_backup(key: str):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
await s3.delete_object(key)
deleted, failed = await backups.delete_many([key])
if failed:
raise RuntimeError("Не удалось удалить файл из бакета")
# --- журнал событий ---
class EventOut(BaseModel):
id: str # evt_…
ts: datetime
type: str
entity_type: str
entity_id: str | None
device_id: str | None
job_id: str | None
actor: str
message: str
data: dict | None = None
model_config = {"from_attributes": True}
@field_validator("data", mode="before")
@classmethod
def _parse(cls, v):
return json.loads(v) if isinstance(v, str) else v
class JournalSettingsIn(BaseModel):
retention_days: int # 0 — без ограничения по сроку
max_rows: int # 0 — без ограничения по числу записей
@router.get("/events/settings")
async def get_journal_settings():
"""Настройки ротации журнала и сводка (число записей, самая старая запись)."""
return {**settings.journal(), **events.stats()}
@router.put("/events/settings")
async def put_journal_settings(body: JournalSettingsIn):
"""Сохраняет настройки ротации и сразу применяет ротацию. Очистки журнала через API нет — она выполняется
только в UI через окно с вводом пароля пользователя."""
return {**settings.save_journal(body.retention_days, body.max_rows), **events.stats()}
@router.get("/events", response_model=list[EventOut])
async def list_events(entity_id: str | None = None, type: str | None = None, device_id: str | None = None,
job_id: str | None = None, actor: str | None = None, date_from: date | None = None,
date_to: date | None = None, q: str | None = None, before: str | None = None, limit: int = 100):
"""Журнал событий, новые сверху. type — точный тип (device.created) или группа (device, job, backup);
actor — ui, api, poller, system или anonymous. Постранично: before=<ID последней записи предыдущей страницы>."""
return events.list_events(entity_id=entity_id, type_=type, device_id=device_id, job_id=job_id, actor=actor,
date_from=date_from, date_to=date_to, text=q, before=before, limit=limit)
@router.get("/events/{event_id}", response_model=EventOut)
async def get_event(event_id: str):
ids.check(event_id, "evt")
return events.get_event(event_id)
# --- задачи ---
@@ -265,5 +322,5 @@ async def list_jobs():
@router.get("/jobs/{job_id}", response_model=JobOut)
async def get_job(job_id: int):
async def get_job(job_id: str):
return jobs.get_job(job_id)
+4
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
poll_interval: int = 30 # период фонового опроса устройств, с; 0 — отключить
update_check_interval: int = 1800 # как часто в опросе проверять обновления ROS (тяжёлый запрос), с
# значения по умолчанию для ротации журнала; действующие настройки хранятся в БД и меняются из UI
events_retention_days: int = 90 # 0 — без ограничения по сроку
events_max_rows: int = 100000 # 0 — без ограничения по числу записей
@lru_cache
def get_settings() -> Settings:
+7 -2
View File
@@ -25,9 +25,14 @@ def init_db(url: str | None = None) -> None:
_SessionLocal = sessionmaker(_engine, expire_on_commit=False)
from app import models # noqa: F401 (регистрация моделей)
Base.metadata.create_all(_engine)
Base.metadata.create_all(_engine) # у новой БД — все таблицы, у старой — только недостающие (events)
_migrate()
from app import migrations
db_file = Path(url.removeprefix("sqlite:///")) if url.startswith("sqlite:///") and ":memory:" not in url else None
migrations.run(_engine, db_file)
def _migrate() -> None:
"""create_all не добавляет колонки в существующие таблицы — докидываем вручную."""
@@ -36,7 +41,7 @@ def _migrate() -> None:
if "use_tls" not in cols:
conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN use_tls BOOLEAN NOT NULL DEFAULT 1")
if "group_id" not in cols:
conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN group_id INTEGER")
conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN group_id VARCHAR(40)")
if "note" not in cols:
conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN note TEXT")
+74
View File
@@ -0,0 +1,74 @@
"""Глобально уникальные идентификаторы сущностей: <префикс>_<uuid7>.
Пример: dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47.
- Между типами — префикс: ID разных сущностей не пересекаются по построению.
- Внутри типа — UUIDv7 (RFC 9562): 48 бит времени (мс) + 12-битный счётчик + 62 случайных бита.
Внутри процесса ID строго возрастают (в том числе при откате часов), поэтому сортировка по ID
совпадает с порядком создания; между процессами уникальность держится на 62 случайных битах и PRIMARY KEY.
- ID не переиспользуются: значение не зависит от того, какие записи уже удалены.
"""
import re
import secrets
import threading
import time
import uuid
from datetime import datetime, timezone
PREFIXES = {
"dev": "устройство",
"grp": "группа",
"job": "задача",
"bkp": "резервная копия",
"evt": "запись журнала",
}
_TAIL = r"[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
_lock = threading.Lock()
_last_ms = 0
_seq = 0
_hist: dict[int, int] = {} # счётчики для «исторических» меток времени (миграция)
def _build(ms: int, seq: int) -> uuid.UUID:
rand_b = secrets.randbits(62)
return uuid.UUID(int=(ms << 80) | (0x7 << 76) | (seq << 64) | (0b10 << 62) | rand_b)
def new_id(prefix: str, at: datetime | None = None) -> str:
"""Новый ID. at — момент создания записи (для миграции старых данных: сохраняет порядок)."""
global _last_ms, _seq
if prefix not in PREFIXES:
raise ValueError(f"Неизвестный префикс ID: {prefix}")
with _lock:
if at is not None:
ms = int(at.replace(tzinfo=at.tzinfo or timezone.utc).timestamp() * 1000)
_hist[ms] = _hist.get(ms, -1) + 1
return f"{prefix}_{_build(ms, min(_hist[ms], 0xFFF))}"
now_ms = time.time_ns() // 1_000_000
if now_ms > _last_ms:
_last_ms, _seq = now_ms, secrets.randbits(11) # случайный старт: ≥2048 ID на миллисекунду
else: # тот же миллисекунда или часы ушли назад — время не уменьшаем
_seq += 1
if _seq > 0xFFF:
_last_ms, _seq = _last_ms + 1, 0
return f"{prefix}_{_build(_last_ms, _seq)}"
def is_id(value: object, prefix: str | None = None) -> bool:
"""Строка похожа на ID (нужного типа, если prefix задан)."""
if not isinstance(value, str):
return False
pat = rf"({'|'.join(PREFIXES)})_{_TAIL}" if prefix is None else rf"{prefix}_{_TAIL}"
return re.fullmatch(pat, value) is not None
def check(value: str, prefix: str) -> str:
"""Проверка ID из пути/запроса: чужой тип или мусор — LookupError (в API это 404 с пояснением)."""
if not is_id(value, prefix):
hint = f"ожидается {PREFIXES[prefix]} ({prefix}_…)"
raise LookupError(f"Неверный идентификатор «{str(value)[:60]}»: {hint}")
return value
def short_id(value: str | None) -> str:
"""Короткий вид для таблиц: последние 8 символов (случайная часть); полный ID — в подсказке."""
return (value or "")[-8:]
+8 -5
View File
@@ -11,7 +11,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.api import v1
from app.config import get_settings
from app.db import init_db
from app.services import jobs, poller
from app.services import backups, jobs, poller, rotation
from app.ui import routes as ui
@@ -20,11 +20,14 @@ async def lifespan(app: FastAPI):
init_db()
jobs.fail_stale_jobs()
task = asyncio.create_task(poller.run()) if get_settings().poll_interval > 0 else None
reconcile = asyncio.create_task(backups.reconcile()) # связать файлы бакета с метаданными (best-effort)
rotator = asyncio.create_task(rotation.run()) # ротация журнала по настройкам
yield
if task:
task.cancel()
with suppress(asyncio.CancelledError):
await task
for t in (task, reconcile, rotator):
if t:
t.cancel()
with suppress(asyncio.CancelledError):
await t
def create_app() -> FastAPI:
+139
View File
@@ -0,0 +1,139 @@
"""Миграции схемы SQLite. Версия хранится в PRAGMA user_version.
v1 — глобально уникальные ID: числовые ID таблиц (devices, device_groups, jobs, backups) заменяются на
<префикс>_<uuid7> (см. app/ids.py) с пересчётом ссылок; порядок записей сохраняется (время в UUIDv7 берётся из
created_at/requested_at). Перед изменениями делается копия файла БД (<файл>.bak-<метка>); всё выполняется
одной транзакцией на «сыром» соединении (DDL в pysqlite иначе не транзакционен) и при любом расхождении в числе строк откатывается.
"""
import json
import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import Engine
from sqlalchemy.dialects import sqlite as sqlite_dialect
from sqlalchemy.schema import CreateTable
from app.ids import new_id
log = logging.getLogger("ros_control.migrations")
SCHEMA_VERSION = 2 # v2: таблица app_settings (создаётся create_all), данные не меняются
TABLES = ("device_groups", "devices", "backups", "jobs")
def _dt(value) -> datetime | None:
try:
return datetime.fromisoformat(str(value)) if value else None
except ValueError:
return None
def _int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _is_legacy(con: sqlite3.Connection) -> bool:
cols = {r[1]: (r[2] or "").upper() for r in con.execute("PRAGMA table_info(devices)")}
return bool(cols) and cols.get("id", "").startswith("INT")
def run(engine: Engine, db_path: Path | None) -> None:
"""Приводит БД к текущей версии схемы. Идемпотентна."""
if db_path is None: # не файловая БД (:memory:) — старых данных быть не может
with engine.begin() as c:
c.exec_driver_sql(f"PRAGMA user_version = {SCHEMA_VERSION}")
return
engine.dispose() # закрыть пул: дальше работаем отдельным соединением
con = sqlite3.connect(db_path, isolation_level=None)
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version >= SCHEMA_VERSION:
return
if version < 1 and _is_legacy(con):
_to_v1(con, db_path)
con.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
finally:
con.close()
def _to_v1(con: sqlite3.Connection, db_path: Path) -> None:
from app.db import Base # noqa: F401
from app import models
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
bak = db_path.with_name(f"{db_path.name}.bak-{stamp}")
dst = sqlite3.connect(bak)
con.backup(dst)
dst.close()
log.warning("Миграция ID: копия БД сохранена в %s", bak)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=OFF")
con.execute("BEGIN IMMEDIATE")
try:
old = {t: con.execute(f"SELECT * FROM {t} ORDER BY id").fetchall() for t in TABLES}
for t in TABLES:
con.execute(f"ALTER TABLE {t} RENAME TO {t}_old")
for tbl in (models.Group.__table__, models.Device.__table__, models.Backup.__table__, models.Job.__table__):
con.execute(str(CreateTable(tbl).compile(dialect=sqlite_dialect.dialect())))
grp: dict[int, str] = {}
for r in old["device_groups"]:
grp[r["id"]] = new_id("grp")
con.execute("INSERT INTO device_groups (id, name) VALUES (?, ?)", (grp[r["id"]], r["name"]))
dev: dict[int, str] = {}
names: dict[int, str] = {}
for r in old["devices"]:
dev[r["id"]] = new_id("dev", _dt(r["created_at"]))
names[r["id"]] = r["name"]
con.execute(
"INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, use_tls, group_id, note,"
" created_at, online, status_json, status_at, last_error, last_backup_requested_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(dev[r["id"]], r["name"], r["host"], r["port"], r["username"], r["password_enc"], r["verify_tls"],
r["use_tls"], grp.get(_int(r["group_id"])), r["note"], r["created_at"], r["online"], r["status_json"],
r["status_at"], r["last_error"], r["last_backup_requested_at"]))
def dev_id(old_id): # ссылка на уже удалённое устройство сохраняет идентичность (один новый ID на старый)
return None if old_id is None else dev.setdefault(old_id, new_id("dev"))
for r in old["backups"]:
key = r["key_binary"] or r["key_rsc"] or ""
parts = key.split("/")
name = names.get(_int(r["device_id"])) or (parts[1] if len(parts) >= 3 else "")
con.execute(
"INSERT INTO backups (id, device_id, device_name, job_id, deleted_at, requested_at, status, key_binary, key_rsc, error)"
" VALUES (?,?,?,?,?,?,?,?,?,?)",
(new_id("bkp", _dt(r["requested_at"])), dev_id(_int(r["device_id"])), name, None, None, r["requested_at"],
r["status"], r["key_binary"], r["key_rsc"], r["error"]))
for r in old["jobs"]:
con.execute(
"INSERT INTO jobs (id, device_id, device_name, type, status, message, created_at, finished_at)"
" VALUES (?,?,?,?,?,?,?,?)",
(new_id("job", _dt(r["created_at"])), dev_id(_int(r["device_id"])), r["device_name"], r["type"], r["status"],
r["message"], r["created_at"], r["finished_at"]))
counts = {t: len(old[t]) for t in TABLES}
for t in TABLES: # сверка числа строк до коммита
n = con.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
if n != counts[t]:
raise RuntimeError(f"Миграция ID: в {t} было {counts[t]} строк, стало {n}")
for t in TABLES:
con.execute(f"DROP TABLE {t}_old")
con.execute(
"INSERT INTO events (id, ts, type, entity_type, entity_id, device_id, job_id, actor, message, data)"
" VALUES (?,?,?,?,?,?,?,?,?,?)",
(new_id("evt"), datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"), "system.migrated", "system", None,
None, None, "system", "Числовые ID заменены на глобально уникальные (dev_/grp_/job_/bkp_)",
json.dumps({"rows": counts, "backup_file": bak.name})))
con.execute("COMMIT")
except Exception:
con.execute("ROLLBACK")
log.exception("Миграция ID не выполнена, БД не изменена (копия: %s)", bak)
raise
log.warning("Миграция ID выполнена: %s", counts)
+43 -7
View File
@@ -5,23 +5,29 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
from app.ids import new_id
def now() -> datetime:
return datetime.now(timezone.utc)
def _pk(prefix: str):
"""Первичный ключ-строка: глобально уникальный ID <prefix>_<uuid7> (см. app/ids.py)."""
return mapped_column(String(40), primary_key=True, default=lambda: new_id(prefix))
class Group(Base):
__tablename__ = "device_groups"
id: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = _pk("grp")
name: Mapped[str] = mapped_column(String(64), unique=True)
class Device(Base):
__tablename__ = "devices"
id: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = _pk("dev")
name: Mapped[str] = mapped_column(String(64), unique=True)
host: Mapped[str] = mapped_column(String(255))
port: Mapped[int] = mapped_column(Integer, default=443)
@@ -29,7 +35,7 @@ class Device(Base):
password_enc: Mapped[str] = mapped_column(Text)
verify_tls: Mapped[bool] = mapped_column(Boolean, default=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # FK не включены — см. groups.delete_group
group_id: Mapped[str | None] = mapped_column(String(40), nullable=True) # FK не включены — см. groups.delete_group
note: Mapped[str | None] = mapped_column(Text, nullable=True) # свободное примечание к устройству
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
@@ -48,10 +54,15 @@ class Device(Base):
class Backup(Base):
"""Резервная копия = пара файлов (.backup + .rsc). Строка не удаляется: при удалении файлов из бакета
ставится deleted_at, ID остаётся в истории."""
__tablename__ = "backups"
id: Mapped[int] = mapped_column(primary_key=True)
device_id: Mapped[int] = mapped_column(ForeignKey("devices.id", ondelete="CASCADE"))
id: Mapped[str] = _pk("bkp")
device_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("devices.id", ondelete="SET NULL"), nullable=True)
device_name: Mapped[str] = mapped_column(String(64), default="") # снимок имени: устройство могут удалить
job_id: Mapped[str | None] = mapped_column(String(40), nullable=True) # задача, создавшая копию
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
status: Mapped[str] = mapped_column(String(16), default="running") # running|done|failed
key_binary: Mapped[str | None] = mapped_column(String(512), nullable=True)
@@ -62,11 +73,36 @@ class Backup(Base):
class Job(Base):
__tablename__ = "jobs"
id: Mapped[int] = mapped_column(primary_key=True)
device_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
id: Mapped[str] = _pk("job")
device_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
device_name: Mapped[str] = mapped_column(String(64), default="")
type: Mapped[str] = mapped_column(String(32)) # backup|ros_update|fw_update
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|done|failed
message: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AppSetting(Base):
"""Настройки, редактируемые из UI (ключ → JSON-значение); значения по умолчанию — из .env."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class Event(Base):
"""Журнал событий: каждая запись имеет свой ID и ссылается на ID сущности."""
__tablename__ = "events"
id: Mapped[str] = _pk("evt")
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
type: Mapped[str] = mapped_column(String(48), index=True) # device.created, job.failed, …
entity_type: Mapped[str] = mapped_column(String(16)) # device|group|job|backup|session|system
entity_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
device_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
actor: Mapped[str] = mapped_column(String(80), default="system") # ui:<пользователь> | api | poller | system
message: Mapped[str] = mapped_column(Text, default="")
data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON с подробностями
+5 -3
View File
@@ -50,9 +50,11 @@ async def list_backups(device_name: str | None = None) -> list[dict]:
return await asyncio.to_thread(_list, prefix)
async def upload_file(path, key: str) -> None:
"""PutObject: загрузка файла в бакет от имени Control Server."""
await asyncio.to_thread(_client().upload_file, str(path), get_settings().s3_bucket, key)
async def upload_file(path, key: str, metadata: dict[str, str] | None = None) -> None:
"""PutObject: загрузка файла в бакет от имени Control Server. metadata — пользовательские метаданные объекта
(x-amz-meta-*): сюда кладутся ID резервной копии и устройства."""
extra = {"ExtraArgs": {"Metadata": metadata}} if metadata else {}
await asyncio.to_thread(_client().upload_file, str(path), get_settings().s3_bucket, key, **extra)
async def delete_object(key: str) -> None:
+45 -1
View File
@@ -1,4 +1,6 @@
import hmac
import threading
import time
from cryptography.fernet import Fernet
from fastapi import Depends, HTTPException
@@ -31,7 +33,49 @@ def check_admin(user: str, password: str) -> bool:
return ok_user and ok_pass
def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
# --- подтверждение действий паролем (очистка журнала) и защита от перебора ---
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
_guard = threading.Lock()
_fails: dict[str, list[float]] = {}
_locked_until: dict[str, float] = {}
def verify_password(user: str, password: str) -> bool:
"""Пароль пользователя сессии (единственный пользователь — ADMIN_USER); сравнение за постоянное время."""
s = get_settings()
return (hmac.compare_digest(user.encode(), s.admin_user.encode())
and hmac.compare_digest(password.encode(), s.admin_password.encode()))
def lockout_remaining(user: str) -> int:
"""Сколько секунд осталось до конца блокировки (0 — не заблокирован)."""
with _guard:
return max(0, int(_locked_until.get(user, 0) - time.monotonic() + 0.999))
def register_failure(user: str) -> int:
"""Учитывает неверный пароль; возвращает число оставшихся попыток (0 — пользователь заблокирован)."""
now = time.monotonic()
with _guard:
recent = [t for t in _fails.get(user, []) if now - t < WINDOW_S] + [now]
_fails[user] = recent
if len(recent) >= FAIL_LIMIT:
_locked_until[user] = now + LOCK_S
_fails[user] = []
return 0
return FAIL_LIMIT - len(recent)
def reset_failures(user: str) -> None:
with _guard:
_fails.pop(user, None)
_locked_until.pop(user, None)
async def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
expected = get_settings().api_token
if cred is None or not hmac.compare_digest(cred.credentials.encode(), expected.encode()):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
from app.services import events # локальный импорт: security загружается раньше сервисов
events.set_actor("api")
+82 -8
View File
@@ -1,38 +1,108 @@
"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
"""Список бэкапов из бакета с фильтрами и связью с метаданными (backups.id = bkp_…)."""
import asyncio
import logging
import threading
from datetime import date
from app import s3
from app.services import devices, groups
from app import ids, s3
from app.config import get_settings
from app.db import session_scope
from app.models import Backup, Device, now
from app.services import devices, events, groups
log = logging.getLogger("ros_control.backups")
_sync_lock = threading.Lock() # один процесс: не создаём дубликаты «сирот» при параллельных запросах
def _kind(key: str) -> str:
return "backup" if key.endswith(".backup") else "rsc" if key.endswith(".rsc") else "other"
def sync_rows(objects: list[dict]) -> dict[str, dict]:
"""Сопоставляет файлы бакета со строками backups (по ключу) и возвращает key -> {backup_id, device_id}.
- файлы без строки (загруженные до появления ID или вручную) получают строку bkp_… по паре <устройство>/<метка>;
- копия, у которой не осталось файлов в бакете, помечается удалённой (строка и ID остаются в истории);
- вернувшиеся файлы снимают пометку."""
keys = {o["key"]: o for o in objects}
with _sync_lock, session_scope() as s:
by_key: dict[str, dict] = {}
for b in s.query(Backup).all():
present = [k for k in (b.key_binary, b.key_rsc) if k and k in keys]
for k in present:
by_key[k] = {"backup_id": b.id, "device_id": b.device_id}
if b.status == "done" and b.deleted_at is None and not present:
b.deleted_at = now()
events.record("backup.deleted", "backup", b.id, f"Файлы резервной копии {b.device_name} удалены из бакета",
device_id=b.device_id, data={"key_binary": b.key_binary, "key_rsc": b.key_rsc}, s=s)
elif present and b.deleted_at is not None:
b.deleted_at = None
orphans: dict[tuple[str, str], dict] = {}
for key, o in keys.items():
parts = key.split("/")
if key in by_key or len(parts) < 3 or _kind(key) == "other":
continue
stem = parts[-1].rsplit(".", 1)[0]
orphans.setdefault((parts[1], stem), {})[_kind(key)] = (key, o["last_modified"])
dev_by_name = {d.name: d.id for d in s.query(Device)}
for (name, _stem), files in orphans.items():
at = max(t for _, t in files.values())
b = Backup(id=ids.new_id("bkp", at), device_id=dev_by_name.get(name), device_name=name, status="done",
requested_at=at, key_binary=files.get("backup", (None,))[0], key_rsc=files.get("rsc", (None,))[0])
s.add(b)
s.flush()
events.record("backup.imported", "backup", b.id, f"Файлы бакета связаны с резервной копией {name}",
device_id=b.device_id, data={"key_binary": b.key_binary, "key_rsc": b.key_rsc}, s=s)
for k in (b.key_binary, b.key_rsc):
if k:
by_key[k] = {"backup_id": b.id, "device_id": b.device_id}
return by_key
async def reconcile() -> None:
"""При старте: привести метаданные в соответствие с бакетом (не критично — сбой не мешает работе)."""
s = get_settings()
if not (s.s3_bucket and s.s3_access_key):
return
try:
objects = await s3.list_backups(None)
events.set_actor("system")
sync_rows(objects)
except Exception: # noqa: BLE001
log.exception("reconcile бэкапов не выполнен")
async def search(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]:
"""Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}).
Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства.
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе id группы.
Даты — по времени изменения объекта (UTC), включительно."""
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе ID группы (grp_…).
Даты — по времени изменения объекта (UTC), включительно. Каждому файлу сопоставлен backup_id."""
group_names = {g["id"]: g["name"] for g in groups.list_groups()}
device_group = {d.name: group_names.get(d.group_id) for d in devices.list_devices()}
want_group = group_names.get(int(group)) if group.isdigit() else None
want_group = group_names.get(group) if ids.is_id(group, "grp") else None
everything = await s3.list_backups(None)
try:
links = sync_rows(everything)
except Exception: # noqa: BLE001 — список важнее связывания
log.exception("sync_rows не выполнен")
links = {}
stats = {"total": len(everything), "size": sum(i["size"] for i in everything)}
q = q.strip().lower()
out = []
for item in everything:
parts = item["key"].split("/")
name = parts[1] if len(parts) >= 3 else ""
item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"])}
link = links.get(item["key"], {})
item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"]),
"backup_id": link.get("backup_id"), "device_id": link.get("device_id")}
if device and name != device:
continue
if group == "none" and item["group"] is not None:
continue
if group.isdigit() and (want_group is None or item["group"] != want_group):
if ids.is_id(group, "grp") and (want_group is None or item["group"] != want_group):
continue
if kind and item["kind"] != kind:
continue
@@ -74,4 +144,8 @@ async def delete_many(keys: list[str]) -> tuple[int, int]:
return False
results = await asyncio.gather(*(one(k) for k in keys))
try: # метаданные: копии, у которых не осталось файлов, помечаются удалёнными (с событием)
sync_rows(await s3.list_backups(None))
except Exception: # noqa: BLE001
log.exception("sync_rows после удаления не выполнен")
return sum(results), len(results) - sum(results)
+36 -13
View File
@@ -1,19 +1,20 @@
import re
from dataclasses import dataclass
from app import security
from app import ids, security
from app.config import get_settings
from app.db import session_scope
from app.models import Device, Group
from app.ros.operations import version_newer
from app.ros.client import RosClient
from app.ros.operations import version_newer
from app.services import events
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # имя попадает в ключи S3 — только безопасные символы
@dataclass
class Conn:
id: int
id: str
name: str
host: str
port: int
@@ -33,7 +34,8 @@ def list_devices() -> list[Device]:
return list(s.query(Device).order_by(Device.name))
def get_device(device_id: int) -> Device:
def get_device(device_id: str) -> Device:
ids.check(device_id, "dev")
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
@@ -41,7 +43,9 @@ def get_device(device_id: int) -> Device:
return d
def _check_group(s, group_id: int | None) -> None:
def _check_group(s, group_id: str | None) -> None:
if group_id is not None:
ids.check(group_id, "grp")
if group_id is not None and s.get(Group, group_id) is None:
raise LookupError(f"Группа {group_id} не найдена")
@@ -56,7 +60,7 @@ def _clean_note(note: str | None) -> str | None:
return note or None
def _resolve_group(s, group_id: int | None, new_group: str | None) -> int | None:
def _resolve_group(s, group_id: str | None, new_group: str | None) -> str | None:
"""Группа устройства. new_group создаёт группу (или берёт существующую с таким именем)
в той же транзакции, что и устройство: при ошибке не остаётся лишней группы."""
name = (new_group or "").strip()
@@ -68,13 +72,14 @@ def _resolve_group(s, group_id: int | None, new_group: str | None) -> int | None
g = Group(name=name)
s.add(g)
s.flush()
events.record("group.created", "group", g.id, f"Группа «{name}» создана", data={"name": name}, s=s)
return g.id
_check_group(s, group_id)
return group_id
def create_device(name, host, port, username, password, verify_tls=False, use_tls=True,
group_id: int | None = None, new_group: str | None = None, note: str | None = None) -> Device:
group_id: str | None = None, new_group: str | None = None, note: str | None = None) -> Device:
validate_name(name)
note = _clean_note(note)
with session_scope() as s:
@@ -86,37 +91,55 @@ def create_device(name, host, port, username, password, verify_tls=False, use_tl
group_id=group_id, note=note)
s.add(d)
s.flush()
events.record("device.created", "device", d.id, f"Устройство {name} добавлено", device_id=d.id,
data={"name": name, "host": host, "port": port, "group_id": group_id}, s=s)
return d
def update_device(device_id: int, **fields) -> Device:
def update_device(device_id: str, **fields) -> Device:
ids.check(device_id, "dev")
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
if fields.get("name") and fields["name"] != d.name: # имя — часть ключей бэкапов в S3
raise ValueError("Имя устройства нельзя изменить")
changed = []
for k in ("host", "port", "username", "verify_tls", "use_tls"):
if fields.get(k) is not None:
if fields.get(k) is not None and getattr(d, k) != fields[k]:
setattr(d, k, fields[k])
changed.append(k)
if "group_id" in fields or fields.get("new_group"): # group_id=None — открепить от группы
d.group_id = _resolve_group(s, fields.get("group_id"), fields.get("new_group"))
gid = _resolve_group(s, fields.get("group_id"), fields.get("new_group"))
if gid != d.group_id:
d.group_id = gid
changed.append("group_id")
if "note" in fields: # пустое примечание — очистить
d.note = _clean_note(fields["note"])
note = _clean_note(fields["note"])
if note != d.note:
d.note = note
changed.append("note")
if fields.get("password"): # пустой пароль = не менять
d.password_enc = security.encrypt(fields["password"])
changed.append("password") # только факт смены, значение в журнал не пишется
if changed:
events.record("device.updated", "device", d.id, f"Устройство {d.name} изменено: {', '.join(changed)}",
device_id=d.id, data={"fields": changed}, s=s)
return d
def delete_device(device_id: int) -> None:
def delete_device(device_id: str) -> None:
ids.check(device_id, "dev")
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
events.record("device.deleted", "device", d.id, f"Устройство {d.name} удалено", device_id=d.id,
data={"name": d.name, "host": d.host}, s=s)
s.delete(d)
def get_conn(device_id: int) -> Conn:
def get_conn(device_id: str) -> Conn:
d = get_device(device_id)
return Conn(d.id, d.name, d.host, d.port, d.username,
security.decrypt(d.password_enc), d.verify_tls, d.use_tls)
+143
View File
@@ -0,0 +1,143 @@
"""Журнал событий: каждая запись имеет свой ID (evt_…) и ссылается на ID сущности.
Актор и текущая задача передаются через ContextVar: их выставляют зависимости авторизации (UI/API),
фоновый опрос и запуск задач (asyncio наследует контекст при create_task).
"""
import json
import logging
from contextvars import ContextVar
from datetime import date, datetime, time as dtime, timedelta, timezone
from sqlalchemy import func, or_
from app.db import session_scope
from app.models import Event, now
log = logging.getLogger("ros_control.events")
_actor: ContextVar[str] = ContextVar("actor", default="system")
_job: ContextVar[str | None] = ContextVar("job_id", default=None)
def set_actor(actor: str) -> None:
"""ui:<пользователь> | api | poller | system."""
_actor.set(actor)
def set_job(job_id: str | None) -> None:
_job.set(job_id)
def current_job() -> str | None:
return _job.get()
def record(type_: str, entity_type: str, entity_id: str | None, message: str = "", *, device_id: str | None = None,
job_id: str | None = None, data: dict | None = None, s=None) -> str | None:
"""Записывает событие. Внутри транзакции вызывающего (s=сессия) — атомарно с изменением; без сессии —
отдельной короткой транзакцией, сбой записи журнала не ломает основную операцию."""
ev = Event(type=type_, entity_type=entity_type, entity_id=entity_id, device_id=device_id,
job_id=job_id or _job.get(), actor=_actor.get(), message=message,
data=json.dumps(data, ensure_ascii=False) if data is not None else None)
if s is not None:
s.add(ev)
s.flush()
return ev.id
try:
with session_scope() as sess:
sess.add(ev)
sess.flush()
return ev.id
except Exception: # noqa: BLE001
log.exception("не удалось записать событие %s", type_)
return None
def _filtered(q, *, entity_id=None, type_=None, device_id=None, job_id=None, actor=None,
date_from: date | None = None, date_to: date | None = None, text: str | None = None):
if entity_id:
q = q.filter(Event.entity_id == entity_id)
if type_:
q = q.filter(Event.type == type_) if "." in type_ else q.filter(Event.type.like(type_ + ".%"))
if device_id:
q = q.filter(Event.device_id == device_id)
if job_id:
q = q.filter(Event.job_id == job_id)
if actor: # «ui» находит ui:<пользователь>
q = q.filter(or_(Event.actor == actor, Event.actor.like(actor + ":%")))
if date_from:
q = q.filter(Event.ts >= datetime.combine(date_from, dtime.min, tzinfo=timezone.utc))
if date_to: # включительно, по UTC
q = q.filter(Event.ts < datetime.combine(date_to + timedelta(days=1), dtime.min, tzinfo=timezone.utc))
if text:
like = f"%{text.strip()}%"
q = q.filter(or_(Event.message.ilike(like), Event.id.ilike(like), Event.entity_id.ilike(like),
Event.device_id.ilike(like), Event.job_id.ilike(like)))
return q
def list_events(*, before: str | None = None, limit: int = 100, **filters) -> list[Event]:
"""Новые сверху. before — курсор: ID последней записи предыдущей страницы (ID сортируется по времени)."""
with session_scope() as s:
q = _filtered(s.query(Event), **filters)
if before:
q = q.filter(Event.id < before)
return list(q.order_by(Event.id.desc()).limit(max(1, min(limit, 500))))
def count(**filters) -> int:
with session_scope() as s:
return _filtered(s.query(func.count(Event.id)), **filters).scalar() or 0
def stats() -> dict:
"""Сводка по журналу: число записей и время самой старой."""
with session_scope() as s:
n, oldest = s.query(func.count(Event.id), func.min(Event.ts)).one()
return {"count": n or 0, "oldest": oldest}
def types() -> list[str]:
with session_scope() as s:
return sorted(t for (t,) in s.query(Event.type).distinct())
def get_event(event_id: str) -> Event:
with session_scope() as s:
ev = s.get(Event, event_id)
if ev is None:
raise LookupError(f"Запись журнала {event_id} не найдена")
return ev
def rotate() -> dict:
"""Ротация по настройкам (services.settings): удаляет записи старше срока и самые старые сверх лимита.
Если что-то удалено — одна запись journal.rotated. Возвращает {'by_age': n, 'by_count': m}."""
from app.services import settings # локально: settings импортирует events
cfg = settings.journal()
by_age = by_count = 0
with session_scope() as s:
if cfg["retention_days"] > 0:
cutoff = now() - timedelta(days=cfg["retention_days"])
by_age = s.query(Event).filter(Event.ts < cutoff).delete(synchronize_session=False)
if cfg["max_rows"] > 0:
extra = (s.query(func.count(Event.id)).scalar() or 0) - cfg["max_rows"]
if extra > 0:
extra += 1 # место под запись journal.rotated: после ротации в журнале ровно max_rows записей
# граница по ID: ID сортируется по времени, удаляем самые старые
bound = s.query(Event.id).order_by(Event.id).offset(extra - 1).limit(1).scalar()
by_count = s.query(Event).filter(Event.id <= bound).delete(synchronize_session=False)
if by_age or by_count:
record("journal.rotated", "journal", None, f"Ротация журнала: удалено записей {by_age + by_count}",
data={"by_age": by_age, "by_count": by_count, **cfg}, s=s)
return {"by_age": by_age, "by_count": by_count}
def clear(user: str) -> int:
"""Удаляет все записи и оставляет одну — о факте очистки (в одной транзакции). Возвращает число удалённых."""
with session_scope() as s:
n = s.query(Event).delete(synchronize_session=False)
record("journal.cleared", "journal", None, f"Журнал очищен пользователем {user}: удалено записей {n}",
data={"deleted": n, "user": user}, s=s)
return n
+24 -7
View File
@@ -1,7 +1,9 @@
from sqlalchemy import func, update
from app import ids
from app.db import session_scope
from app.models import Device, Group
from app.services import events
def _clean(name: str) -> str:
@@ -19,7 +21,8 @@ def list_groups() -> list[dict]:
for g in s.query(Group).order_by(Group.name)]
def get_group(group_id: int) -> Group:
def get_group(group_id: str) -> Group:
ids.check(group_id, "grp")
with session_scope() as s:
g = s.get(Group, group_id)
if g is None:
@@ -35,10 +38,12 @@ def create_group(name: str) -> Group:
g = Group(name=name)
s.add(g)
s.flush()
events.record("group.created", "group", g.id, f"Группа «{name}» создана", data={"name": name}, s=s)
return g
def rename_group(group_id: int, name: str) -> Group:
def rename_group(group_id: str, name: str) -> Group:
ids.check(group_id, "grp")
name = _clean(name)
with session_scope() as s:
g = s.get(Group, group_id)
@@ -46,23 +51,35 @@ def rename_group(group_id: int, name: str) -> Group:
raise LookupError(f"Группа {group_id} не найдена")
if s.query(Group).filter(Group.name == name, Group.id != group_id).first():
raise ValueError(f"Группа «{name}» уже существует")
g.name = name
old, g.name = g.name, name
if old != name:
events.record("group.renamed", "group", g.id, f"Группа «{old}» переименована в «{name}»",
data={"old": old, "new": name}, s=s)
return g
def delete_group(group_id: int) -> None:
def delete_group(group_id: str) -> None:
"""Удаляет группу; устройства остаются и становятся «Без группы» (FK в SQLite не включены)."""
ids.check(group_id, "grp")
with session_scope() as s:
g = s.get(Group, group_id)
if g is None:
raise LookupError(f"Группа {group_id} не найдена")
detached = [d for (d,) in s.query(Device.id).filter(Device.group_id == group_id)]
s.execute(update(Device).where(Device.group_id == group_id).values(group_id=None))
events.record("group.deleted", "group", g.id, f"Группа «{g.name}» удалена", data={"name": g.name, "detached_devices": detached}, s=s)
s.delete(g)
def move_devices(device_ids: list[int], group_id: int | None) -> None:
def move_devices(device_ids: list[str], group_id: str | None) -> None:
"""Переносит устройства в группу (None — «Без группы»)."""
for i in device_ids:
ids.check(i, "dev")
with session_scope() as s:
if group_id is not None and s.get(Group, group_id) is None:
raise LookupError(f"Группа {group_id} не найдена")
if group_id is not None:
ids.check(group_id, "grp")
if s.get(Group, group_id) is None:
raise LookupError(f"Группа {group_id} не найдена")
s.execute(update(Device).where(Device.id.in_(device_ids)).values(group_id=group_id))
events.record("devices.moved", "group", group_id, f"Перенесено устройств: {len(device_ids)}",
data={"device_ids": device_ids, "group_id": group_id}, s=s)
+23 -11
View File
@@ -1,10 +1,11 @@
"""Фоновые задачи: запись в таблицу jobs, параллелизм ограничен MAX_CONCURRENCY."""
import asyncio
from app import ids
from app.config import get_settings
from app.db import session_scope
from app.models import Device, Job, now
from app.services import ops
from app.services import events, ops
JOB_TYPES = {
"backup": ops.run_backup,
@@ -23,15 +24,19 @@ def _semaphore() -> asyncio.Semaphore:
return _sem
def _finish(job_id: int, status: str, message: str) -> None:
def _finish(job_id: str, status: str, message: str) -> None:
with session_scope() as s:
j = s.get(Job, job_id)
j.status, j.message = status, message
if status in ("done", "failed"):
j.finished_at = now()
etype = {"running": "job.started", "done": "job.done", "failed": "job.failed"}[status]
events.record(etype, "job", job_id, message or f"Задача {j.type}: {status}", device_id=j.device_id,
job_id=job_id, data={"type": j.type, "status": status}, s=s)
async def _run(job_id: int, job_type: str, device_id: int) -> None:
async def _run(job_id: str, job_type: str, device_id: str) -> None:
events.set_job(job_id) # события и бэкап, созданные внутри задачи, ссылаются на её ID
async with _semaphore():
_finish(job_id, "running", "")
try:
@@ -40,11 +45,13 @@ async def _run(job_id: int, job_type: str, device_id: int) -> None:
_finish(job_id, "failed", str(e))
def start_jobs(job_type: str, device_ids: list[int]) -> list[int]:
"""Создаёт задачи для списка устройств и запускает их в фоне. Возвращает id задач."""
def start_jobs(job_type: str, device_ids: list[str]) -> list[str]:
"""Создаёт задачи для списка устройств и запускает их в фоне. Возвращает ID задач."""
if job_type not in JOB_TYPES:
raise ValueError(f"Неизвестный тип задачи: {job_type}")
job_ids = []
for did in device_ids:
ids.check(did, "dev")
started = []
with session_scope() as s:
for did in device_ids:
d = s.get(Device, did)
@@ -53,12 +60,14 @@ def start_jobs(job_type: str, device_ids: list[int]) -> list[int]:
j = Job(device_id=did, device_name=d.name, type=job_type)
s.add(j)
s.flush()
job_ids.append((j.id, did))
for jid, did in job_ids:
task = asyncio.create_task(_run(jid, job_type, did))
events.record("job.created", "job", j.id, f"Задача {job_type} для {d.name} создана", device_id=did,
job_id=j.id, data={"type": job_type}, s=s)
started.append((j.id, did))
for jid, did in started:
task = asyncio.create_task(_run(jid, job_type, did)) # наследует контекст (актор)
_tasks.add(task)
task.add_done_callback(_tasks.discard)
return [jid for jid, _ in job_ids]
return [jid for jid, _ in started]
def list_jobs(limit: int = 50) -> list[Job]:
@@ -66,7 +75,8 @@ def list_jobs(limit: int = 50) -> list[Job]:
return list(s.query(Job).order_by(Job.id.desc()).limit(limit))
def get_job(job_id: int) -> Job:
def get_job(job_id: str) -> Job:
ids.check(job_id, "job")
with session_scope() as s:
j = s.get(Job, job_id)
if j is None:
@@ -79,3 +89,5 @@ def fail_stale_jobs() -> None:
with session_scope() as s:
for j in s.query(Job).filter(Job.status.in_(("pending", "running"))):
j.status, j.message, j.finished_at = "failed", "Прервано перезапуском сервера", now()
events.record("job.failed", "job", j.id, j.message, device_id=j.device_id, job_id=j.id,
data={"type": j.type, "status": "failed"}, s=s)
+27 -14
View File
@@ -11,19 +11,25 @@ from app.db import session_scope
from app.models import Backup, Device, now
from app.ros import operations as ros
from app.ros.client import RosError
from app.services import devices
from app.services import devices, events
def _save_status(device_id: int, fields: dict) -> None:
def _save_status(device_id: str, fields: dict) -> None:
with session_scope() as s:
d = s.get(Device, device_id)
if d is not None: # устройство могли удалить во время опроса
was = d.online
for k, v in fields.items():
setattr(d, k, v)
d.status_at = now()
if "online" in fields and fields["online"] is not was: # в журнал — только смены состояния, не каждый опрос
up = fields["online"]
events.record("device.online" if up else "device.offline", "device", d.id,
f"Устройство {d.name} {'доступно' if up else 'недоступно'}", device_id=d.id,
data={"error": (fields.get("last_error") or "")[:300]} if not up else None, s=s)
async def refresh_status(device_id: int) -> None:
async def refresh_status(device_id: str) -> None:
"""Полный опрос: статус, версии и проверка обновлений. Недоступность — не исключение."""
conn = devices.get_conn(device_id)
try:
@@ -35,7 +41,7 @@ async def refresh_status(device_id: int) -> None:
_save_status(device_id, fields)
async def poll_device(device_id: int, full: bool = False) -> None:
async def poll_device(device_id: str, full: bool = False) -> None:
"""Фоновый опрос. Лёгкий (один запрос system/resource) обновляет online, uptime и версию ROS;
полный — если нужна проверка обновлений (full) либо устройство было недоступно/ещё не опрошено
(после возвращения версии могли измениться)."""
@@ -51,18 +57,18 @@ async def poll_device(device_id: int, full: bool = False) -> None:
_save_status(device_id, dict(online=True, status_json=json.dumps(status), last_error=None))
async def refresh_many(device_ids: list[int] | None = None) -> None:
ids = device_ids if device_ids is not None else [d.id for d in devices.list_devices()]
async def refresh_many(device_ids: list[str] | None = None) -> None:
targets = device_ids if device_ids is not None else [d.id for d in devices.list_devices()]
sem = asyncio.Semaphore(get_settings().max_concurrency)
async def one(i: int):
async def one(i: str):
async with sem:
await refresh_status(i)
await asyncio.gather(*(one(i) for i in ids))
await asyncio.gather(*(one(i) for i in targets))
async def run_backup(device_id: int) -> str:
async def run_backup(device_id: str) -> str:
"""Бэкап .backup + .rsc: создать на устройстве → скачать через REST во временную папку →
загрузить в S3 от имени сервера → удалить файлы с устройства."""
conn = devices.get_conn(device_id)
@@ -75,11 +81,15 @@ async def run_backup(device_id: int) -> str:
key_bin, key_rsc = files[f"{base}.backup"], files[f"{base}.rsc"]
with session_scope() as s:
b = Backup(device_id=device_id, key_binary=key_bin, key_rsc=key_rsc)
b = Backup(device_id=device_id, device_name=conn.name, job_id=events.current_job(),
key_binary=key_bin, key_rsc=key_rsc)
s.add(b)
s.get(Device, device_id).last_backup_requested_at = b.requested_at = now()
s.flush()
backup_id = b.id
events.record("backup.created", "backup", backup_id, f"Резервная копия {conn.name} запрошена", device_id=device_id,
data={"key_binary": key_bin, "key_rsc": key_rsc}, s=s)
meta = {"backup-id": backup_id, "device-id": device_id} # связь файлов в бакете с метаданными
status, error = "failed", "прервано"
try:
@@ -90,7 +100,7 @@ async def run_backup(device_id: int) -> str:
for name, key in files.items():
local = Path(tmp) / name
await ros.download_file(c, name, local)
await s3.upload_file(local, key)
await s3.upload_file(local, key, meta)
finally:
for name in files: # на устройстве файлы не остаются ни при успехе, ни при сбое
try:
@@ -105,20 +115,23 @@ async def run_backup(device_id: int) -> str:
with session_scope() as s:
b = s.get(Backup, backup_id)
b.status, b.error = status, error
events.record("backup.done" if status == "done" else "backup.failed", "backup", backup_id,
"Резервная копия загружена в S3" if status == "done" else f"Резервная копия не создана: {error}",
device_id=device_id, data={"key_binary": key_bin, "key_rsc": key_rsc} if status == "done" else {"error": error}, s=s)
return f"Бэкап загружен в S3: {key_bin}, {key_rsc}"
async def set_channel(device_id: int, channel: str) -> None:
async def set_channel(device_id: str, channel: str) -> None:
async with devices.open_client(devices.get_conn(device_id)) as c:
await ros.set_channel(c, channel)
await refresh_status(device_id)
async def run_ros_update(device_id: int) -> str:
async def run_ros_update(device_id: str) -> str:
async with devices.open_client(devices.get_conn(device_id)) as c:
return await ros.install_ros_update(c)
async def run_fw_update(device_id: int) -> str:
async def run_fw_update(device_id: str) -> str:
async with devices.open_client(devices.get_conn(device_id)) as c:
return await ros.upgrade_firmware(c)
+5 -4
View File
@@ -4,12 +4,12 @@ import logging
import time
from app.config import get_settings
from app.services import devices, ops
from app.services import devices, events, ops
log = logging.getLogger("ros_control.poller")
async def cycle(last_full: dict[int, float]) -> None:
async def cycle(last_full: dict[str, float]) -> None:
"""Один проход по всем устройствам (параллельно, не более MAX_CONCURRENCY одновременно)."""
s = get_settings()
ids = [d.id for d in devices.list_devices()]
@@ -18,7 +18,7 @@ async def cycle(last_full: dict[int, float]) -> None:
sem = asyncio.Semaphore(s.max_concurrency)
started = time.monotonic()
async def one(device_id: int) -> None:
async def one(device_id: str) -> None:
async with sem:
full = started - last_full.get(device_id, float("-inf")) >= s.update_check_interval
try:
@@ -35,7 +35,8 @@ async def cycle(last_full: dict[int, float]) -> None:
async def run() -> None:
last_full: dict[int, float] = {}
events.set_actor("poller")
last_full: dict[str, float] = {}
interval = get_settings().poll_interval
while True:
try:
+20
View File
@@ -0,0 +1,20 @@
"""Ротация журнала: при старте и далее раз в час (настройки — services.settings)."""
import asyncio
import logging
from app.services import events
log = logging.getLogger("ros_control.rotation")
INTERVAL_S = 3600
async def run() -> None:
events.set_actor("system")
while True:
try:
done = await asyncio.to_thread(events.rotate)
if done["by_age"] or done["by_count"]:
log.warning("ротация журнала: %s", done)
except Exception: # noqa: BLE001 — сбой ротации не должен останавливать приложение
log.exception("ротация журнала не выполнена")
await asyncio.sleep(INTERVAL_S)
+53
View File
@@ -0,0 +1,53 @@
"""Настройки, редактируемые из UI и хранимые в БД (app_settings); значения по умолчанию — из .env."""
import json
from app.config import get_settings
from app.db import session_scope
from app.models import AppSetting, now
from app.services import events
RETENTION_MAX_DAYS = 3650
ROWS_MIN, ROWS_MAX = 100, 10_000_000
def _get(key: str, default):
with session_scope() as s:
row = s.get(AppSetting, key)
return json.loads(row.value) if row is not None else default
def journal() -> dict:
"""Действующие настройки ротации: {'retention_days': int, 'max_rows': int}; 0 — без ограничения."""
cfg = get_settings()
return {"retention_days": _get("events.retention_days", cfg.events_retention_days),
"max_rows": _get("events.max_rows", cfg.events_max_rows)}
def validate_journal(retention_days, max_rows) -> dict:
try:
days, rows = int(str(retention_days).strip()), int(str(max_rows).strip())
except ValueError:
raise ValueError("Укажите целые числа") from None
if not 0 <= days <= RETENTION_MAX_DAYS:
raise ValueError(f"Срок хранения: целое число от 0 до {RETENTION_MAX_DAYS} (0 — без ограничения)")
if rows != 0 and not ROWS_MIN <= rows <= ROWS_MAX:
raise ValueError(f"Максимум записей: 0 (без ограничения) или от {ROWS_MIN} до {ROWS_MAX:,}".replace(",", " "))
return {"retention_days": days, "max_rows": rows}
def save_journal(retention_days, max_rows) -> dict:
"""Сохраняет настройки ротации, пишет событие и сразу применяет ротацию."""
new = validate_journal(retention_days, max_rows)
old = journal()
with session_scope() as s:
for key, value in (("events.retention_days", new["retention_days"]), ("events.max_rows", new["max_rows"])):
row = s.get(AppSetting, key)
if row is None:
s.add(AppSetting(key=key, value=json.dumps(value)))
else:
row.value, row.updated_at = json.dumps(value), now()
if new != old:
events.record("journal.settings_changed", "journal", None, "Изменены настройки ротации журнала",
data={"old": old, "new": new}, s=s)
events.rotate()
return new
+2
View File
@@ -12,5 +12,7 @@ ICONS = {
"download": '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
"router": '<rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 17h.01M11 17h.01M8 14l-2-6M16 14l2-6"/>',
"trash": '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
"settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"copy": '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>',
"arrow": '<path d="M5 12h14M13 6l6 6-6 6"/>',
}
+181 -22
View File
@@ -1,4 +1,5 @@
"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API."""
import json
from datetime import date
from pathlib import Path
from urllib.parse import urlencode
@@ -7,10 +8,10 @@ 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 import ids, 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.services import backups, devices, events, groups, jobs, ops, settings
from app.ui.icons import ICONS
@@ -28,6 +29,20 @@ 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", "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
@@ -36,9 +51,11 @@ class LoginRequired(Exception):
pass
def require_login(request: Request) -> None:
if not request.session.get("user"):
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)
@@ -59,8 +76,10 @@ def _done(request: Request, fallback: str = "/"):
return RedirectResponse(fallback, status_code=303)
def _opt_int(value: str) -> int | None:
return int(value) if str(value).strip().isdigit() else None
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_
@@ -73,6 +92,8 @@ def _flt(params) -> dict:
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
@@ -117,8 +138,12 @@ async def login_form(request: Request):
@router.post("/login")
async def login(request: Request, username: str = Form(), password: str = Form()):
if not security.check_admin(username, password):
events.set_actor("anonymous")
events.record("auth.failed", "session", None, "Неудачная попытка входа в UI", data={"user": username[:64]})
return _render(request, "login.html", error="Неверный логин или пароль")
request.session["user"] = username
events.set_actor(f"ui:{username}")
events.record("auth.login", "session", None, f"Вход в UI: {username}", data={"user": username})
return RedirectResponse("/", status_code=303)
@@ -153,7 +178,7 @@ async def jobs_table(request: Request):
@router.post("/ui/refresh", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def refresh(request: Request, device_ids: list[int] = Form(default=[])):
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 _visible(flt)])
@@ -161,7 +186,7 @@ async def refresh(request: Request, device_ids: list[int] = Form(default=[])):
@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=[]),
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=jobs.list_jobs(15), flash="Выберите устройства")
@@ -175,7 +200,7 @@ async def batch(request: Request, action: str, device_ids: list[int] = Form(defa
@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="")):
async def device_action(request: Request, device_id: str, action: str, channel: str = Form(default="")):
"""Действие над одним устройством (пункты меню «⋯» в строке таблицы)."""
if action == "refresh":
await ops.refresh_status(device_id)
@@ -188,10 +213,10 @@ async def device_action(request: Request, device_id: int, action: str, channel:
@router.post("/ui/move", dependencies=[Depends(require_login)])
async def move(device_ids: list[int] = Form(default=[]), group_id: str = Form(""), next: str = Form("/")):
async 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_int(group_id))
groups.move_devices(device_ids, _opt_id(group_id, "grp"))
return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/", status_code=303)
@@ -223,12 +248,12 @@ def _group_form(request: Request, group, name: str, error: str | None = None):
@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 ""
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)])
async def dialog_device_edit(request: Request, device_id: int):
async 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())
@@ -240,7 +265,7 @@ async def dialog_group_new(request: Request):
@router.get("/ui/dialog/group/{group_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def dialog_group_edit(request: Request, group_id: int):
async 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)
@@ -270,30 +295,30 @@ async def group_create(request: Request, name: str = Form()):
@router.post("/groups/{group_id}/rename", dependencies=[Depends(require_login)])
async def group_rename(request: Request, group_id: int, name: str = Form()):
async 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)])
async def group_delete(request: Request, group_id: int):
async 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[int | None, str | None]:
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_int(group_id), None
return _opt_id(group_id, "grp"), 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 ""
v["group_id"] = group if ids.is_id(group, "grp") else ""
return _device_form(request, None, v)
@@ -314,13 +339,13 @@ async def device_create(request: Request, name: str = Form(), host: str = Form()
@router.get("/devices/{device_id}/edit", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def device_edit(request: Request, device_id: int):
async 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)])
async def device_update(request: Request, device_id: int, host: str = Form(), port: int = Form(443),
async 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("")):
@@ -338,7 +363,7 @@ async def device_update(request: Request, device_id: int, host: str = Form(), po
@router.post("/devices/{device_id}/delete", dependencies=[Depends(require_login)])
async def device_delete(request: Request, device_id: int):
async def device_delete(request: Request, device_id: str):
devices.delete_device(device_id)
return _done(request)
@@ -390,3 +415,137 @@ async def backup_delete(key: str = Form()):
raise ValueError("Недопустимый ключ")
await s3.delete_object(key)
return RedirectResponse("/backups", 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)])
async 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)])
async 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)])
async 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)])
async 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)])
async def dialog_events_settings(request: Request):
return _settings_dialog(request)
@router.post("/events/settings", dependencies=[Depends(require_login)])
async 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)])
async def dialog_events_clear(request: Request):
return _clear_dialog(request)
@router.post("/events/clear", dependencies=[Depends(require_login)])
async 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")
+24 -2
View File
@@ -85,7 +85,7 @@
$("#modal-body [autofocus], #modal-body input:not([type=hidden]):not([readonly])")?.focus();
const sel = $("select[name=group_id][data-newgroup]"); if (sel) $("#new-group-field").hidden = sel.value !== "__new__";
}
syncSelection(); syncFilters();
syncSelection(); syncFilters(); syncEnables();
});
// --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат ---
@@ -100,6 +100,28 @@
}
setInterval(autoRefresh, REFRESH_MS);
const init = () => { syncSelection(); syncFilters(); };
// --- журнал: копирование ID, подтверждение очистки, строка таблицы открывает запись ---
function legacyCopy(text) { // запасной путь: http вне localhost или нет разрешения на буфер обмена
const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
legacyCopy(text);
return Promise.resolve();
}
document.addEventListener("click", (e) => {
const c = e.target.closest("[data-copy]");
if (c) { copyText(c.dataset.copy).then(() => { const t = c.title; c.title = "Скопировано"; c.setAttribute("aria-label", "Скопировано"); setTimeout(() => { c.title = t; c.setAttribute("aria-label", "Копировать"); }, 1200); }); return; }
const tr = e.target.closest("tr.clickable");
if (tr && !e.target.closest("a, button")) tr.querySelector("a.row-link")?.click();
});
const syncEnables = () => $$("input[data-enables]").forEach((i) => { const t = $(i.dataset.enables); if (t) t.disabled = i.disabled || !i.value; });
document.addEventListener("input", (e) => { if (e.target.matches("input[data-enables]")) syncEnables(); });
const init = () => { syncSelection(); syncFilters(); syncEnables(); };
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init();
})();
+25
View File
@@ -205,3 +205,28 @@ dialog.modal::backdrop { background: var(--backdrop); }
.card > table.grid:first-child thead th:first-child { border-top-left-radius: 11px; }
.card > table.grid:first-child thead th:last-child { border-top-right-radius: 11px; }
.card > table.grid:last-child tbody tr:last-child td { border-bottom: 0; }
/* ---------- Журнал ---------- */
.badge.plain { background: var(--neu-b); color: var(--neu-t); }
table.grid.events { min-width: 1000px; }
.grid.events td { height: 48px; }
.grid.events td.c-small:first-child { white-space: nowrap; }
.grid.events td.msg-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.grid.events tr.clickable { cursor: pointer; }
.grid.events tr.clickable:hover td { background: var(--acc50); }
.grid.events .ent { margin-right: 8px; }
.grid.events a.row-link { color: var(--muted); }
.more { display: flex; justify-content: center; align-items: center; min-height: 0; }
.more:not(:empty) { height: 64px; border-bottom: 1px solid var(--line); }
dialog.modal:has(.w640) { width: 640px; }
.kv { display: grid; grid-template-columns: 130px 1fr; gap: 12px; align-items: center; min-height: 32px; }
.kv > span:first-child { color: var(--muted); font-size: 13px; }
.kv > div { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
.kv .mono { overflow-wrap: anywhere; }
pre.json { margin: 0; padding: 12px; border-radius: 8px; background: var(--head); border: 1px solid var(--line); font: 400 13px/1.4 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.sum { padding: 12px; border-radius: 8px; background: var(--head); border: 1px solid var(--line); font: 400 13px/1.5 var(--font); color: var(--muted); }
.sum b { color: var(--text); font-weight: 600; }
.note.info { background: var(--acc50); color: var(--accd); }
.input.invalid { border-color: var(--bad-t); }
.input:disabled { background: var(--disabled-bg); color: var(--disabled-t); cursor: not-allowed; }
.field-err { font: 500 13px/1.4 var(--font); color: var(--bad-t); }
+1 -1
View File
@@ -26,7 +26,7 @@
<label class="lbl">Группа
<select class="field" name="group_id" data-newgroup style="width:100%">
<option value="">— без группы —</option>
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if v.group_id == g.id|string }}>{{ g.name }}</option>{% endfor %}
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if v.group_id == g.id }}>{{ g.name }}</option>{% endfor %}
<option value="__new__" {{ "selected" if v.group_id == "__new__" }}>+ Новая группа…</option>
</select>
</label>
+23
View File
@@ -0,0 +1,23 @@
{% import "_ui.html" as ui %}
<div class="w640">
<header class="modal-head">
<h2 id="modal-title">Запись журнала</h2>
{% if in_modal %}<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>{% endif %}
</header>
<div class="modal-body">
{% macro idcell(value) -%}<span class="mono">{{ value }}</span><button type="button" class="btn ghost-n icon" data-copy="{{ value }}" aria-label="Копировать" title="Копировать">{{ ui.icon("copy") }}</button>{%- endmacro %}
<div class="kv"><span>ID записи</span><div>{{ idcell(e.id) }}</div></div>
<div class="kv"><span>Время</span><div>{{ e.ts|dts }} UTC</div></div>
<div class="kv"><span>Тип</span><div><span class="badge {{ ev_kind(e.type) }}">{{ e.type }}</span></div></div>
<div class="kv"><span>Актор</span><div>{{ e.actor }}</div></div>
<div class="kv"><span>Сущность</span><div>{% if e.entity_id %}{{ ev_entity(e.entity_type) }} · {{ idcell(e.entity_id) }}{% else %}<span class="muted">{{ ev_entity(e.entity_type) }}</span>{% endif %}</div></div>
<div class="kv"><span>Устройство</span><div>{% if e.device_id %}{% if device %}{{ device.name }} · {% endif %}{{ idcell(e.device_id) }}{% if not device %}<span class="muted">(удалено)</span>{% endif %}{% else %}<span class="muted">—</span>{% endif %}</div></div>
<div class="kv"><span>Задача</span><div>{% if e.job_id %}{{ idcell(e.job_id) }}{% else %}<span class="muted">—</span>{% endif %}</div></div>
<div class="kv"><span>Сообщение</span><div>{{ e.message or "—" }}</div></div>
{% if data_json %}<div style="font:500 13px/1.3 var(--font)">Данные</div><pre class="json">{{ data_json }}</pre>{% endif %}
</div>
<footer class="modal-foot">
<span class="grow"></span>
{% if in_modal %}<button type="button" class="btn secondary" data-close>Закрыть</button>{% else %}<a class="btn secondary" href="/events">К журналу</a>{% endif %}
</footer>
</div>
+2
View File
@@ -0,0 +1,2 @@
{% include "_events_rows.html" %}
{% include "_events_more.html" %}
+20
View File
@@ -0,0 +1,20 @@
{% import "_ui.html" as ui %}
<form method="post" action="/events/clear" {% if in_modal %}hx-post="/events/clear" hx-target="#modal-body"{% endif %}>
<header class="modal-head">
<h2 id="modal-title">Очистить журнал</h2>
{% if in_modal %}<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>{% endif %}
</header>
<div class="modal-body">
<div class="note">Будет удалено <b>{{ st.count|num }} {{ st.count|plural("запись", "записи", "записей") }}</b> без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.</div>
<label class="lbl"><span>Пароль пользователя <b style="font-weight:600">{{ user }}</b> <small>подтверждение нужно только для этого действия</small></span>
<input class="input {{ 'invalid' if error }}" type="password" name="password" placeholder="Введите пароль" autocomplete="current-password"
data-enables="#clear-confirm" {{ "disabled" if locked else "autofocus" }} {% if error %}aria-invalid="true" aria-describedby="clear-error"{% endif %}></label>
{% if locked %}<div class="form-err" role="alert">Слишком много неверных попыток. Повторите через {{ minutes }} мин.</div>
{% elif error %}<div class="field-err" id="clear-error" role="alert">{{ error }}</div>{% endif %}
</div>
<footer class="modal-foot">
<span class="grow"></span>
{% if in_modal %}<button type="button" class="btn ghost-n" data-close>Отмена</button>{% else %}<a class="btn ghost-n" href="/events">Отмена</a>{% endif %}
<button class="btn danger" id="clear-confirm" disabled>{{ ui.icon("trash") }}Очистить журнал</button>
</footer>
</form>
+2
View File
@@ -0,0 +1,2 @@
<div id="events-more" class="more"{% if oob %} hx-swap-oob="true"{% endif %}>{% if more %}<button type="button" class="btn secondary" hx-get="/ui/events?{{ qs }}{{ '&' if qs }}before={{ next_before }}&shown={{ shown }}" hx-target="#events-body" hx-swap="beforeend">Показать ещё 100</button>{% endif %}</div>
<div id="events-foot" class="tfoot"{% if oob %} hx-swap-oob="true"{% endif %}>Показано {{ shown }} из {{ total|num }} {{ total|plural("записи", "записей", "записей") }}</div>
+12
View File
@@ -0,0 +1,12 @@
{% for e in rows %}
<tr class="clickable">
<td class="c-small">{{ e.ts|dts }}</td>
<td><span class="badge {{ ev_kind(e.type) }}">{{ e.type }}</span></td>
<td><span class="ent">{{ ev_entity(e.entity_type) }}</span><span class="c-mono muted" {% if e.entity_id %}title="{{ e.entity_id }}"{% endif %}>{{ (e.entity_id|short_id) if e.entity_id else "—" }}</span></td>
<td class="msg-cell" title="{{ e.message }}">{{ e.message }}</td>
<td class="c-small">{{ e.actor }}</td>
<td><a class="c-mono row-link" href="/events/{{ e.id }}" hx-get="/ui/dialog/event/{{ e.id }}" hx-target="#modal-body" title="{{ e.id }}">{{ e.id|short_id }}</a></td>
</tr>
{% else %}
{% if not oob %}<tr><td colspan="6" class="empty">Записей не найдено.</td></tr>{% endif %}
{% endfor %}
+21
View File
@@ -0,0 +1,21 @@
{% import "_ui.html" as ui %}
<form method="post" action="/events/settings" {% if in_modal %}hx-post="/events/settings" hx-target="#modal-body"{% endif %}>
<header class="modal-head">
<h2 id="modal-title">Настройки журнала</h2>
{% if in_modal %}<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>{% endif %}
</header>
<div class="modal-body">
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
<div class="sum">Сейчас в журнале: <b>{{ st.count|num }} {{ st.count|plural("запись", "записи", "записей") }}</b> · старейшая {{ st.oldest|dt }}</div>
<label class="lbl"><span>Хранить записи, дней <small>0 — без ограничения по сроку</small></span>
<input class="input" type="number" name="retention_days" min="0" max="3650" value="{{ values.retention_days }}" required></label>
<label class="lbl"><span>Максимум записей <small>0 — без ограничения по количеству</small></span>
<input class="input" type="number" name="max_rows" min="0" value="{{ values.max_rows }}" required></label>
<div class="note info">Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.</div>
</div>
<footer class="modal-foot">
<span class="grow"></span>
{% if in_modal %}<button type="button" class="btn ghost-n" data-close>Отмена</button>{% else %}<a class="btn ghost-n" href="/events">Отмена</a>{% endif %}
<button class="btn primary">Сохранить</button>
</footer>
</form>
+3 -3
View File
@@ -11,12 +11,12 @@
{% if flash %}<p class="err">{{ flash }}</p>{% endif %}
<div class="card">
<table class="grid jobs">
<colgroup><col style="width:56px"><col style="width:16%"><col style="width:16%"><col style="width:130px"><col><col style="width:150px"></colgroup>
<thead><tr><th>#</th><th>Устройство</th><th>Операция</th><th>Статус</th><th>Сообщение</th><th>Создана (UTC)</th></tr></thead>
<colgroup><col style="width:96px"><col style="width:16%"><col style="width:16%"><col style="width:130px"><col><col style="width:150px"></colgroup>
<thead><tr><th>ID</th><th>Устройство</th><th>Операция</th><th>Статус</th><th>Сообщение</th><th>Создана (UTC)</th></tr></thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="muted">{{ j.id }}</td>
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
<td style="font-weight:500">{{ j.device_name }}</td>
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW"}.get(j.type, j.type) }}</td>
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
+2 -2
View File
@@ -20,7 +20,7 @@
<select class="field {{ 'on' if flt.group }}" name="group" onchange="this.form.submit()" aria-label="Группа" style="width:170px">
<option value="">Группа: любая</option>
<option value="none" {{ "selected" if flt.group == "none" }}>Группа: без группы</option>
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if flt.group == g.id|string }}>Группа: {{ g.name }}</option>{% endfor %}
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if flt.group == g.id }}>Группа: {{ g.name }}</option>{% endfor %}
</select>
<select class="field {{ 'on' if flt.device }}" name="device" onchange="this.form.submit()" aria-label="Устройство" style="width:190px">
<option value="">Устройство: любое</option>
@@ -57,7 +57,7 @@
<td style="font-weight:600">{{ i.device or "—" }}</td>
<td class="muted">{{ i.group or "—" }}</td>
<td><span class="badge">{{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }}</span></td>
<td class="c-mono" title="{{ i.key }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ i.key.split("/")[-1] }}</td>
<td class="c-mono" title="{{ i.key }}{% if i.backup_id %} · {{ i.backup_id }}{% endif %}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ i.key.split("/")[-1] }}</td>
<td class="c-small">{{ i.size|size }}</td>
<td class="c-small">{{ i.last_modified|dt }}</td>
<td class="c-menu">
+1 -1
View File
@@ -11,7 +11,7 @@
</head>
<body>
{% if request.session.get("user") %}
{% set nav = [("Устройства", "/", "devices"), ("Группы", "/groups", "groups"), ("Резервные копии", "/backups", "backups")] %}
{% set nav = [("Устройства", "/", "devices"), ("Группы", "/groups", "groups"), ("Резервные копии", "/backups", "backups"), ("Журнал", "/events", "events")] %}
<header class="app-header">
<a class="brand" href="/"><span class="brand-logo">{{ ui.icon("router") }}</span>ros_control</a>
<nav class="nav" aria-label="Разделы">
+1 -1
View File
@@ -8,7 +8,7 @@
</div>
<div class="actions">
<button type="button" class="btn secondary" hx-post="/ui/refresh" hx-include="#dev-filters" hx-target="#devices" hx-indicator="#busy">{{ ui.icon("refresh") }}Обновить статус</button>
<button type="button" class="btn primary" hx-get="/ui/dialog/device{{ '?group=' ~ flt.group if flt.group.isdigit() }}" hx-target="#modal-body">{{ ui.icon("plus") }}Добавить устройство</button>
<button type="button" class="btn primary" hx-get="/ui/dialog/device{{ '?group=' ~ flt.group if flt.group.startswith('grp_') }}" hx-target="#modal-body">{{ ui.icon("plus") }}Добавить устройство</button>
</div>
</div>
+4
View File
@@ -0,0 +1,4 @@
{% extends "base.html" %}
{% block content %}
<div class="card page-card" style="max-width:640px">{% include "_event_dialog.html" %}</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% import "_ui.html" as ui %}
{% block content %}
<div class="page-head">
<div>
<h1>Журнал</h1>
<div class="sub">{{ st.count|num }} {{ st.count|plural("запись", "записи", "записей") }} · старейшая {{ st.oldest|dt }} · ротация: {{ rotation }}</div>
</div>
<div class="actions">
<a class="btn secondary" href="/events{{ '?' ~ qs if qs }}">{{ ui.icon("refresh") }}Обновить</a>
<button type="button" class="btn secondary" hx-get="/ui/dialog/events-settings" hx-target="#modal-body">{{ ui.icon("settings") }}Настройки</button>
<button type="button" class="btn danger" hx-get="/ui/dialog/events-clear" hx-target="#modal-body">{{ ui.icon("trash") }}Очистить журнал</button>
</div>
</div>
<div class="card">
<div class="tools">
<form method="get" action="/events">
<select class="field {{ 'on' if flt.type }}" name="type" onchange="this.form.submit()" aria-label="Тип" style="width:170px">
<option value="">Тип: любой</option>
{% for c in categories %}<option value="{{ c }}" {{ "selected" if flt.type == c }}>Тип: {{ c }}</option>{% endfor %}
{% for t in types %}<option value="{{ t }}" {{ "selected" if flt.type == t }}>Тип: {{ t }}</option>{% endfor %}
</select>
<select class="field {{ 'on' if flt.actor }}" name="actor" onchange="this.form.submit()" aria-label="Актор" style="width:170px">
<option value="">Актор: любой</option>
{% for a in actors %}<option value="{{ a }}" {{ "selected" if flt.actor == a }}>Актор: {{ a }}</option>{% endfor %}
</select>
<select class="field {{ 'on' if flt.device }}" name="device" onchange="this.form.submit()" aria-label="Устройство" style="width:200px">
<option value="">Устройство: любое</option>
{% for d in devices %}<option value="{{ d.id }}" {{ "selected" if flt.device == d.id }}>Устройство: {{ d.name }}</option>{% endfor %}
</select>
<label class="datefield">с <input type="date" name="date_from" value="{{ flt.date_from }}" onchange="this.form.submit()" aria-label="Дата с"></label>
<label class="datefield">по <input type="date" name="date_to" value="{{ flt.date_to }}" onchange="this.form.submit()" aria-label="Дата по"></label>
<label class="search">{{ ui.icon("search") }}<input type="search" name="q" value="{{ flt.q }}" placeholder="Сообщение или ID" aria-label="Поиск по сообщению или ID" style="width:240px"></label>
<span class="grow"></span>
{% if flt_active %}<a class="btn ghost" href="/events">Сбросить ({{ flt_active }})</a>{% endif %}
</form>
</div>
<div class="table-wrap">
<table class="grid events">
<colgroup><col style="width:176px"><col style="width:180px"><col style="width:150px"><col><col style="width:110px"><col style="width:96px"></colgroup>
<thead><tr><th>Время (UTC)</th><th>Тип</th><th>Сущность</th><th>Сообщение</th><th>Актор</th><th>ID записи</th></tr></thead>
<tbody id="events-body">{% include "_events_rows.html" %}</tbody>
</table>
</div>
{% include "_events_more.html" %}
</div>
{% endblock %}
@@ -0,0 +1,61 @@
# План: 016 — глобально уникальные ID для всех сущностей и журнал событий
## Context
Сейчас у сущностей числовые ID «по таблицам» (`INTEGER PRIMARY KEY`): у устройства №1, задачи №1 и бэкапа №1 одинаковый ID, а без `AUTOINCREMENT` SQLite выдаёт `max(id)+1`, то есть **ID удалённой последней записи повторно используется**. Журнала событий нет (есть только задачи), а метаданные бэкапа (`backups`) со списком файлов в бакете не связаны — страница берёт файлы прямо из S3. Данные сейчас: 6 устройств, 4 группы, 32 задачи, 23 бэкапа.
Цель: у каждой сущности — группа, устройство, задача, бэкап, запись журнала — свой ID, который не пересекается с ID других сущностей и никогда не повторяется.
Решения (согласованы): формат **префикс типа + UUIDv7**; **журнал событий в БД**; числовые ID **заменяются везде** (с миграцией данных).
## ID: как гарантируется уникальность
`app/ids.py` (новый), формат `<префикс>_<uuid7>`: `dev_`, `grp_`, `job_`, `bkp_`, `evt_`; например `dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47`.
- **Между типами** — префикс: ID разных типов не пересекаются по построению, а не «по вероятности».
- **Внутри типа** — UUIDv7 (RFC 9562): время в мс + 12-битный счётчик + 62 случайных бита (в исходном плане было ошибочно указано 74 случайных бита); собственный генератор (в Python 3.12 `uuid.uuid7` нет), монотонный внутри процесса (счётчик в пределах миллисекунды, при откате часов время не уменьшается). Сортировка по ID = сортировка по времени создания.
- **Не переиспользуются**: ID случайный/временной, удаление записи ничего не «освобождает».
- **БД**: `PRIMARY KEY` в каждой таблице — дубликат не сохранится, а вызовет явную ошибку, а не тихую перезапись.
- **Проверка входа**: `parse_id(value, prefix)` — ID чужого типа в пути API/UI (например `job_…` в `/devices/…`) отклоняется (404 с пояснением).
## Схема и связи (`app/models.py`)
Все PK — `String(40)`, генерируются на стороне приложения (`default=lambda: new_id("dev")`), все ссылки — строки с тем же форматом.
- `device_groups.id` = `grp_…`; `devices.id` = `dev_…`, `devices.group_id` → `grp_…`.
- `jobs.id` = `job_…`, `jobs.device_id` → `dev_…` (без FK, как сейчас: история переживает удаление устройства; имя хранится в `device_name`).
- `backups.id` = `bkp_…` — **один бэкап = пара файлов** (`.backup` + `.rsc`); новые поля: `job_id` (задача, создавшая бэкап), `device_name` (снимок имени для истории), `deleted_at`. Удаление файлов из бакета помечает бэкап удалённым, строка и ID остаются.
- **Связь с бакетом**: при загрузке файлы получают S3 user-metadata `backup-id`, `device-id` (`s3.upload_file(..., metadata=…)`), ключи остаются прежними (`backups/<имя>/<метка>.<расширение>`). `services/backups.search` сопоставляет объекты с `backups` по ключу и отдаёт `backup_id` в API.
- **`events` — новая таблица** (журнал): `id evt_…`, `ts` (UTC, индекс), `type`, `entity_type`, `entity_id` (индекс), `device_id`, `job_id`, `actor` (`ui:<пользователь>` / `api` / `poller` / `system`), `message`, `data` (JSON).
## Журнал событий (`app/services/events.py`, новый)
`events.record(type, entity_type, entity_id, message, *, device_id=None, job_id=None, data=None)`; актор берётся из `ContextVar`, который выставляют зависимости `require_login` (UI), `require_api_token` (API) и опрос/задачи (`poller`, `system`; задачи наследуют контекст).
Типы: `device.created|updated|deleted`, `device.online|offline` (**только смены состояния**, не каждый опрос), `group.created|renamed|deleted`, `devices.moved`, `job.created|started|done|failed`, `backup.created|done|failed|deleted`, `auth.login|failed`, `system.migrated`.
API (только чтение): `GET /api/v1/events` (`entity_id`, `type`, `device_id`, `job_id`, `limit`, `before` — курсор по ID, т.к. ID сортируется по времени), `GET /api/v1/events/{id}`. UI-страницы журнала в этом изменении нет (требует макета — отдельная задача).
## Миграция существующих данных (`app/migrations.py`, новый; вызывается из `db.init_db`)
Версионируется через `PRAGMA user_version` (0 → 1), идемпотентна.
1. Перед изменениями — копия файла БД (`sqlite3` online backup) → `ros_control.db.bak-<метка>` рядом с БД (в томе).
2. Для каждой таблицы строится соответствие «старый числовой ID → новый»; временная часть UUIDv7 берётся из `created_at`/`requested_at`, поэтому порядок записей сохраняется; таблицы пересоздаются (`*_new` → копирование с заменой ссылок `group_id`, `device_id` → `drop` → `rename`) одной транзакцией.
3. Запись события `system.migrated` (сколько строк по таблицам).
4. `backups.reconcile()` при старте (best-effort, без S3 не падает): файлы бакета без строки в `backups` (старые/загруженные вручную) получают строку `bkp_…` по паре `<имя>/<метка>`. У уже лежащих в бакете объектов S3-metadata не проставляется (переписывать объекты не будем) — связь через БД.
## Затрагиваемый код (паттерн повторяется)
`int`-ID заменяется на `str` с проверкой префикса: `app/api/v1.py` (пути, `device_ids: list[str]`, `group_id`), `app/ui/routes.py` (`_opt_int` → `_opt_id(prefix)`, `isdigit` в фильтре `f_group` → `is_id("grp", …)`), `app/services/{devices,groups,jobs,ops,backups,poller}.py`, шаблоны (`d.id`, `g.id`, `j.id` подставляются в URL как есть). В таблице «Задачи» колонка «#» становится «ID» с коротким видом (последние 8 символов, моноширинным шрифтом; полный ID в подсказке) — это единственное видимое отличие от утверждённого макета; UUIDv7 в таблицах целиком не помещается.
Запись событий добавляется в места изменений: `services/devices.py`, `groups.py`, `jobs.py` (`_finish`), `ops.py` (`run_backup`, `poll_device`/`_save_status` — смена online↔offline), `ui/routes.py` (логин), `backups.delete_many`.
## Проверка
- **Тесты** (`pytest`, сейчас 12): существующие обновляются под строковые ID; новые (минимум): (1) `ids`: 100 тыс. ID по всем префиксам — без дублей, строго возрастают внутри типа, префиксы не пересекаются, `parse_id` отклоняет чужой тип; (2) миграция: временная БД в **старой схеме** → после миграции ID с префиксами, ссылки (`group_id`, `device_id`) сохранены, число строк совпадает, повторный запуск ничего не меняет; (3) события: создание устройства, жизненный цикл задачи, смена online→offline, вход/отказ записываются со ссылками на ID и актором.
- **Репетиция на копии реальной БД**: копия файла из тома → миграция во временном каталоге → сверка числа строк и связей (устройство ↔ группа ↔ задачи ↔ бэкапы) до обращения к боевой БД.
- **Боевая миграция**: пересборка контейнера; проверка `GET /api/v1/devices|groups|jobs|events` (все ID с префиксами, число записей прежнее, есть `.bak`); UI в браузере (Playwright): вкладки групп, окна устройства/групп, выбор строк, меню, фильтры, групповое удаление бэкапов (на собственных тестовых файлах), «Задачи» с коротким ID; опрос продолжает работать.
- **S3-metadata**: загрузка тестового файла через `s3.upload_file` с метаданными в `backups/qa-id/…`, `head_object` подтверждает `backup-id`, файл удаляется. Полный бэкап на реальном устройстве (создаёт файлы, `.rsc` с секретами) — **только после вашего подтверждения**.
- Документы: `docs/changes/016-unique-ids-and-event-log/{plan,summary}.md`, README (раздел «Идентификаторы», API событий, примеры с `dev_…`), история изменений.
## Риски и оговорки
- **Ломающее изменение API/URL**: старые curl-скрипты с числовыми ID перестанут работать. Откат — из `.bak` файла БД и предыдущего коммита.
- Пересоздание таблиц SQLite при работающем приложении: миграция выполняется на старте до запуска опроса; в БД одновременно пишет один процесс.
- Уникальность между несколькими процессами обеспечена случайной частью (62 бита) и PK, а не общим счётчиком; монотонность порядка гарантируется в пределах процесса.
- Журнал растёт: пишутся только смены состояния (не каждый опрос); срок хранения/очистка — отдельная доработка при необходимости.
@@ -0,0 +1,27 @@
# Итоги: 016 — глобально уникальные ID и журнал событий
## Сделано
- **ID** (`app/ids.py`): у каждой сущности — `<префикс>_<uuid7>`: `dev_` устройство, `grp_` группа, `job_` задача, `bkp_` резервная копия, `evt_` запись журнала.
Типы не пересекаются по построению (префикс); внутри типа UUIDv7 = время (мс) + 12-битный счётчик + 62 случайных бита, строго возрастает внутри процесса, сортировка по ID = по времени создания;
ID не переиспользуются (в отличие от прежних `max(id)+1`); PRIMARY KEY в каждой таблице; ID чужого типа в пути/запросе → 404 с пояснением, числовые ID больше не существуют.
- **Журнал событий** (`events`, `app/services/events.py`): каждая запись со своим `evt_` ID, ссылкой на ID сущности, актором (`ui:<пользователь>` / `api` / `poller` / `system`) и данными.
Типы: `device.created|updated|deleted|online|offline`, `group.created|renamed|deleted`, `devices.moved`, `job.created|started|done|failed`, `backup.created|done|failed|imported|deleted`, `auth.login|failed`, `system.migrated`.
В `online/offline` пишутся только смены состояния; пароли в журнал не попадают. API: `GET /api/v1/events` (фильтры `entity_id`, `type`, `device_id`, `job_id`, `before`, `limit`), `GET /api/v1/events/{id}`. UI-страницы журнала нет (нужен макет).
- **Резервные копии**: одна копия = пара файлов с одним `bkp_` ID; строка хранит `job_id` задачи и снимок имени устройства; файлы загружаются в S3 с метаданными `backup-id` и `device-id`;
файлы бакета без строки получают ID автоматически (при старте и при просмотре списка), копии без файлов помечаются удалёнными (`deleted_at`), строка и ID остаются в истории. В API списка файлов есть `backup_id`, в UI ID виден в подсказке.
- **Миграция** (`app/migrations.py`, `PRAGMA user_version` 0→1): числовые ID заменены везде с пересчётом ссылок, порядок записей сохранён (время в ID берётся из `created_at`), выполняется одной транзакцией, сверяет число строк, перед этим создаёт копию БД (`ros_control.db.bak-<метка>` в томе).
- **API/UI**: все ID — строки; в таблице «Задачи» колонка «ID» показывает последние 8 символов (полный — в подсказке) — единственное видимое отличие от утверждённого макета.
## Проверено
- `pytest`: 17 из 17 (12 прежних обновлены, 5 новых: уникальность 50 тыс. ID и типы, миграция из старой схемы, журнал событий, API журнала и валидация ID, связь бэкапов с бакетом).
- Репетиция на копии боевой БД, затем боевая миграция: 4 группы, 6 устройств, 32 задачи, 23 бэкапа — число строк, связи «устройство → группа → задачи → бэкапы» и порядок сохранены; 70 ID, все уникальны; повторный запуск ничего не меняет.
- Сверка с бакетом: 4 копии (08:26–09:38), чьих файлов уже нет в бакете, помечены удалёнными; все 24 файла в бакете связаны с 12 активными копиями.
- Живой журнал: создание тестового устройства (актор `api`), уход в offline (актор `poller`), удаление; числовой ID и ID чужого типа → 404. Полный сценарий в браузере на мигрированных данных без ошибок JS.
- S3: объект загружается с метаданными, значения совпадают. Yandex Object Storage возвращает ключи в виде `Backup-Id`, `Device-Id` — при чтении сравнивать без учёта регистра. Тестовый объект удалён.
## Оговорки
- **Ломающее изменение**: старые curl-скрипты с числовыми ID не работают. Откат — из `ros_control.db.bak-*` в томе и предыдущего коммита.
- Уже лежащие в бакете объекты метаданных S3 не получили (переписывать объекты не стали): связь с ними — через БД.
- Полный бэкап на реальном устройстве после изменения не запускался (создаёт файлы, `.rsc` с секретами) — метаданные S3 проверены отдельным тестовым объектом; первый штатный бэкап проверит их на реальных файлах.
- Журнал не ротируется (растёт только при изменениях, опрос пишет лишь смены состояния); срок хранения/очистка — при необходимости отдельной доработкой.
- Пробные записи проверки (создание/удаление тестового устройства и группы) остались в журнале как обычные события.
@@ -0,0 +1,59 @@
# План: 017 — журнал событий в UI, ротация и защищённая очистка
Статус: **выполнено** — макет утверждён (этап A), реализация выполнена (этап B), итоги — в `summary.md`.
## Context
Журнал событий (`events`, 016) сейчас читается только через API. Нужно: (1) страница «Журнал» в UI; (2) настройки ротации; (3) кнопка очистки журнала, которая срабатывает **только через модальное окно с вводом пароля пользователя**.
Решения (согласованы): **сначала макет на ревью**, реализация после его одобрения; настройки ротации — **в БД, редактируются из UI** (значения по умолчанию из `.env`); ротация **по возрасту и по числу записей**; очистка **удаляет всё и оставляет запись о факте очистки**.
Работа идёт в два этапа. **Этап A (макет) выполняется сразу после одобрения этого плана и заканчивается остановкой на ваше ревью.** Этап B (код) — только после того, как макет утверждён.
## Этап A — макет (артефакт `claude.ai/artifact/QdhgQWbQEWRYtBoAWMjGq6`, новые листы)
Оформление — тем же генератором и компонентами, что и утверждённые листы (h36 r8, метки h22 r6, карточки r12, IBM Plex); существующие листы не меняются, кроме заметки на холсте о новом пункте меню.
1. **«Журнал»** — верхнее меню получает пятый пункт «Журнал» (в макете показан на новых листах; в приложении добавится во все экраны). Заголовок «Журнал», подзаголовок со сводкой («N записей · старейшая … · ротация: 90 дней / 100 000»). Кнопки справа: «Обновить» (secondary), «Настройки» (secondary), «Очистить журнал» (secondary, красный текст — тот же вид «опасного» действия, что «Удалить» на бэкапах). Primary-кнопки на странице нет.
Карточка: полоса фильтров (Тип ▾, Актор ▾, Устройство ▾, период с/по, поиск по сообщению или ID, «Сбросить (N)»), таблица: **Время (UTC) · Тип (метка по смыслу: ok/warn/bad/run/neutral) · Сущность (вид + короткий ID) · Сообщение · Актор · ID записи (короткий, моноширинный; полный — в подсказке и в окне записи)**, футер «Показано N из M» и кнопка «Показать ещё» (постраничная подгрузка по курсору).
2. **«Журнал · запись»** — окно с полным содержимым записи: `evt_…`, тип, время, актор, ID сущности, устройство, задача, сообщение, данные (JSON), кнопки «Копировать» у ID; закрывается «Закрыть».
3. **«Журнал · настройки ротации»** — окно: «Хранить записи, дней» и «Максимум записей» (0 = не ограничивать), справка («ротация раз в час и при сохранении удаляет самые старые записи и фиксируется записью в журнале»), сводка «сейчас N записей, старейшая …», «Отмена» / «Сохранить» (primary).
4. **«Журнал · очистка»** — два состояния рядом: первичное и «неверный пароль». Окно: предупреждение (жёлтая заметка: «будет удалено N записей без возможности восстановления; останется одна запись об очистке»), поле «Пароль пользователя <имя>» (type=password), кнопка «Очистить» (secondary, красный текст) **неактивна, пока пароль не введён**, «Отмена». Состояние ошибки: красное сообщение «Неверный пароль» и состояние блокировки «Слишком много попыток, повторите через N мин».
5. Заметка на холсте: пункт «Журнал» добавляется в верхнее меню всех экранов. Результат — ссылка на артефакт и вопросы на ревью; **дальше — стоп до вашего ответа.**
## Этап B — реализация (после одобрения макета)
**Данные и настройки.**
- Таблица `app_settings(key PK, value JSON, updated_at)` (`app/models.py`, миграция v2 в `app/migrations.py` через `create_all`, `user_version` 1→2). Ключи: `events.retention_days`, `events.max_rows`.
- `app/services/settings.py` (новый): чтение/запись с валидацией (целые, `0` = без ограничения, верхние границы), значения по умолчанию из `Settings` (`EVENTS_RETENTION_DAYS=90`, `EVENTS_MAX_ROWS=100000`, `app/config.py`, `.env.example`); изменение пишет событие `journal.settings_changed` (старое/новое).
**Ротация** (`app/services/events.py`, `app/services/rotation.py` — новый).
- `rotate()`: удаляет записи старше срока и, если записей больше лимита, самые старые сверх лимита (по возрастанию ID — ID сортируется по времени); пакетами, чтобы не блокировать БД; если что-то удалено — одна запись `journal.rotated` (сколько, по какой причине, действующие настройки).
- Запуск: при старте, сразу после сохранения настроек и раз в час фоновой задачей в `lifespan` (`app/main.py`, рядом с опросом; актор `system`).
**Очистка** (`events.clear(user)`; маршрут только в UI).
- `POST /events/clear` (нужна сессия): пароль сверяется с паролем **пользователя сессии** (`security.verify_admin_password`, `hmac.compare_digest`; логин берётся из сессии, а не из формы). Неверный пароль → окно остаётся открытым с ошибкой, событие `journal.clear_denied` (без пароля), счётчик попыток; после 5 неверных за 10 минут — блокировка на 10 минут (в памяти, по пользователю) с сообщением о времени ожидания.
- Успех: в одной транзакции удаляются все записи и добавляется `journal.cleared` (кто, сколько удалено); окно закрывается, страница обновляется (`HX-Refresh`).
- **Через API очистки нет** (только чтение журнала и настроек) — иначе токен обходил бы окно с паролем; прямой POST без пароля отклоняется.
**UI** (`app/ui/routes.py`, шаблоны `events.html`, `_events_rows.html`, `_event_dialog.html`, `_events_settings.html`, `_events_clear.html`, `base.html` — пункт меню, `static/style.css` — только новые классы на существующих токенах, `static/app.js` — кнопка «Копировать»).
- `GET /events` — страница с фильтрами (GET-форма, как «Бэкапы»: тип/группа типов, актор, устройство, период, поиск; закладки), «Показать ещё» — HTMX-подгрузка следующих 100 по курсору `before=<evt_…>` (тот же `events.list_events`, к нему добавляются фильтры по актору, периоду и тексту).
- Окна через существующую инфраструктуру `<dialog>` + HTMX: `/ui/dialog/event/{id}`, `/ui/dialog/events-settings`, `/ui/dialog/events-clear`; ошибки — внутри окна, успех — `HX-Refresh` (как у окон устройства/групп).
- Кнопка «Очистить» в окне неактивна, пока поле пароля пусто (JS); проверка — на сервере.
**API** (`app/api/v1.py`): `GET/PUT /api/v1/events/settings` (настройки ротации и сводка); фильтры `actor`, `date_from`, `date_to`, `q` в `GET /api/v1/events`. Очистки нет.
**Документы:** `docs/changes/017-events-ui-rotation/{plan,summary}.md`; README (раздел «Журнал», настройки `.env`, история).
## Проверка
- **Макет (этап A):** публикация листов; сверка при ревью — на вашей стороне.
- **Тесты** (`pytest`, сейчас 17; +3): (1) ротация: по возрасту, по числу, `0` = без ограничения, запись `journal.rotated`; (2) очистка: неверный пароль не удаляет и пишет `journal.clear_denied`, верный удаляет всё и оставляет одну `journal.cleared`, блокировка после 5 неверных, `DELETE /api/v1/events` не существует, POST без пароля отклонён; (3) страница `/events`: фильтры, курсор «Показать ещё», окно записи, сохранение настроек с валидацией.
- **Браузер (Playwright) на копии боевой БД** — второй экземпляр приложения на другом порту с копией файла БД и выключенным опросом, чтобы не стереть ваш реальный журнал: страница и фильтры, окно записи, настройки (сохранение, ошибка валидации), очистка (неверный пароль → ошибка, блокировка, верный → пустой журнал с одной записью об очистке), ротация по срокам на искусственно состаренных записях, тёмная тема и узкое окно (меню/окна не обрезаются). На боевом журнале очистку **не запускаю** без вашей команды.
- Регрессия: весь прежний набор тестов и сценарий интерфейса (устройства, группы, бэкапы) без ошибок.
## Риски и оговорки
- Изменение настроек (уменьшение срока/лимита) сразу удаляет старые записи — это фиксируется в журнале (`journal.settings_changed`, `journal.rotated`); пароль для смены настроек не требуется (по вашему ТЗ он нужен только для очистки).
- Пароль пользователя — единственный (`ADMIN_PASSWORD` из `.env`); счётчик блокировки хранится в памяти процесса и сбрасывается перезапуском.
- Журнал не заменяет резервное копирование БД: очистка необратима (кроме восстановления из `.bak`/копии тома).
@@ -0,0 +1,20 @@
# Итоги: 017 — журнал в UI, ротация, очистка с паролем
## Сделано (по утверждённому макету)
- **Страница «Журнал»** (пункт меню): сводка («N записей · старейшая … · ротация: …»), фильтры (тип и группа типов, актор, устройство, период, поиск по сообщению/ID; закладки), таблица (время UTC, тип цветной меткой, сущность, сообщение, актор, короткий ID), «Показать ещё 100» (подгрузка по курсору ID), клик по строке или по ID открывает окно записи с полными ID, данными JSON и кнопками «Копировать».
- **Настройки ротации** (окно): срок хранения (дней) и максимум записей, `0` — без ограничения. Хранятся в БД (`app_settings`, схема v2), значения по умолчанию из `.env` (`EVENTS_RETENTION_DAYS=90`, `EVENTS_MAX_ROWS=100000`). Ротация: при старте, раз в час и сразу после сохранения; по сроку и по числу записей (после неё в журнале ровно `max_rows`, без «пилы»); оставляет `journal.rotated`; изменение настроек пишет `journal.settings_changed`.
- **Очистка**: окно с паролем пользователя сессии (постоянное сравнение, пароль не пишется в журнал и не возвращается в поле). Кнопка «Очистить» неактивна, пока поле пустое; неверный пароль — событие `journal.clear_denied` и счётчик попыток («Осталось попыток: N»); 5 неверных за 10 минут — блокировка на 10 минут (`journal.clear_locked`). Успех: в одной транзакции удаляются все записи и остаётся одна `journal.cleared` (кто, сколько). POST не из окна отклоняется; **в API очистки нет** (только `GET/PUT /api/v1/events/settings` и чтение журнала с новыми фильтрами `actor`, `date_from`, `date_to`, `q`).
## Проверено
- `pytest`: 20 из 20 (+3: ротация по сроку/числу/«0 = без ограничения»/валидация; очистка: пароль, блокировка, одна оставшаяся запись, отсутствие API-очистки; страница журнала: фильтры, курсор, окно записи, настройки).
- Браузер (Playwright) на копии боевой БД во втором экземпляре (порт 8766, опрос и S3 выключены; боевой журнал не затронут): пункт меню, подгрузка 100+100+69 записей, фильтры (тип, актор, «Сбросить (2)»), окно записи по клику строки, «Копировать», настройки (ошибка валидации в окне; срок 30 дней удалил 8 искусственно состаренных записей и оставил `journal.rotated`/`journal.settings_changed`), очистка (кнопка неактивна → активна; ошибки «Осталось попыток: 4/3»; верный пароль → одна запись `journal.cleared` с `deleted: 273`), блокировка после 5 неверных, тёмная тема, узкое окно; ошибок JS нет. Стартовая ротация подтверждена (при запуске копии удалены 6 записей старше 90 дней).
- Боевая система пересобрана: схема v2, журнал цел (16 записей), настройки по умолчанию 90 дней / 100 000 записей.
## Отличия от макета
- В состоянии «неверный пароль» поле пароля очищается (макет показывал введённые точки): введённый пароль не возвращается с сервера. Кнопка «Очистить» снова неактивна до нового ввода.
- Найдено и исправлено при проверке: время в таблице переносилось на вторую строку (колонка расширена), необработанная ошибка при запрете буфера обмена (добавлен запасной путь копирования), отключённое поле пароля не было серым.
## Оговорки
- Единственный пользователь — `ADMIN_USER`; блокировка попыток хранится в памяти процесса (сбрасывается перезапуском).
- Уменьшение срока/лимита в настройках сразу удаляет старые записи (пароль для настроек по ТЗ не требуется); действие фиксируется в журнале.
- Очистка необратима (кроме восстановления из копии БД/тома). Боевой журнал при проверке не очищался.
+3 -1
View File
@@ -1,7 +1,7 @@
import pytest
from cryptography.fernet import Fernet
from app import db
from app import db, security
from app.config import get_settings
@@ -11,10 +11,12 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("SECRET_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("API_TOKEN", "test-token")
monkeypatch.setenv("S3_BUCKET", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY", "") # без ключей S3: сверка бакета при старте отключена
monkeypatch.setenv("POLL_INTERVAL", "0") # фоновый опрос в тестах выключен
monkeypatch.setenv("ADMIN_USER", "admin") # не зависеть от реального .env
monkeypatch.setenv("ADMIN_PASSWORD", "pw")
get_settings.cache_clear()
security.reset_failures("admin") # блокировки очистки журнала — в памяти процесса
db.init_db()
yield
get_settings.cache_clear()
+280 -7
View File
@@ -1,17 +1,21 @@
import asyncio
import html
import json
from datetime import date, datetime, timezone
import re
import sqlite3
from datetime import date, datetime, timedelta, timezone
import httpx
import pytest
from fastapi.testclient import TestClient
from app import s3, security
from app import db, ids, s3, security
from app.main import create_app
from app.models import Backup, Device
from app.models import Backup, Device, Event, now
from app.db import session_scope
from app.ros import operations as ros
from app.ros.client import RosClient
from app.services import backups, devices, groups, ops
from app.services import backups, devices, events, groups, jobs, ops, settings
def ros_client(handler) -> RosClient:
@@ -53,8 +57,11 @@ async def test_backup_flow(monkeypatch):
uploaded = {}
async def fake_upload(path, key): # содержимое читаем до удаления временной папки
metas = {}
async def fake_upload(path, key, metadata=None): # содержимое читаем до удаления временной папки
uploaded[key] = open(path, "rb").read()
metas[key] = metadata
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
@@ -65,7 +72,11 @@ async def test_backup_flow(monkeypatch):
posts = [c for c in calls if c.startswith("POST")]
assert posts == ["POST /rest/system/backup/save", "POST /rest/export",
"POST /rest/execute", "POST /rest/execute"] # по одному блоку на файл
assert "show-sensitive" in export_body # .rsc с секретами: из него можно восстановить всё
assert "show-sensitive" in export_body
with session_scope() as s: # файлы в бакете связаны с метаданными: ID копии и устройства в S3-metadata
b = s.query(Backup).one()
assert ids.is_id(b.id, "bkp") and b.device_id == d.id and b.device_name == "r1"
assert all(m == {"backup-id": b.id, "device-id": d.id} for m in metas.values()) and len(metas) == 2 # .rsc с секретами: из него можно восстановить всё
assert calls.count("DELETE /rest/file/*1") == 2 # файлы удалены с устройства
assert sorted(uploaded.values()) == [b"hello", b"hello"]
assert all(k.startswith("backups/r1/") for k in uploaded)
@@ -181,7 +192,7 @@ def test_create_device_via_ui_binds_group(monkeypatch):
assert r.status_code == 303
# имя устройства в таблице открывает окно изменения (без JS — запасную страницу)
page = c.get("/").text
assert 'class="dev-link" href="/devices/1/edit" hx-get="/ui/dialog/device/1"' in page
assert re.search(r'class="dev-link" href="/devices/(dev_[0-9a-f-]+)/edit" hx-get="/ui/dialog/device/\1"', page)
# 2) «Новая группа…» из окна: группа создаётся и привязывается, окно просит обновить страницу
r = c.post("/devices/new", data={**form, "name": "b", "group_id": "__new__", "new_group": "Склад"}, headers=htmx)
assert r.headers["HX-Refresh"] == "true"
@@ -238,7 +249,11 @@ def test_bulk_delete_backups(monkeypatch):
async def fake_delete(key):
removed.append(key)
async def empty_list(device=None): # после удаления сервис сверяет метаданные с бакетом
return []
monkeypatch.setattr(s3, "delete_object", fake_delete)
monkeypatch.setattr(s3, "list_backups", empty_list)
ok = ["backups/a/1.backup", "backups/a/1.rsc"]
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": "pw"})
@@ -295,3 +310,261 @@ def test_device_name_is_immutable():
assert "readonly" in page and 'name="name"' not in page
dev = devices.get_device(d["id"])
assert (dev.name, dev.host) == ("r1", "10.0.0.7")
def test_ids_are_unique_typed_and_ordered():
"""Все ID глобально уникальны: не повторяются, типы не пересекаются, внутри типа растут со временем."""
seen, last = set(), {}
for i in range(50_000):
prefix = list(ids.PREFIXES)[i % len(ids.PREFIXES)]
v = ids.new_id(prefix)
assert v not in seen and v > last.get(prefix, "")
assert ids.is_id(v, prefix) and not any(ids.is_id(v, o) for o in ids.PREFIXES if o != prefix)
seen.add(v)
last[prefix] = v
with pytest.raises(LookupError): # ID другого типа в пути/запросе отклоняется
ids.check(ids.new_id("job"), "dev")
with pytest.raises(LookupError):
devices.get_device("1") # числовые ID больше не существуют
LEGACY_DDL = """
CREATE TABLE devices (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, host VARCHAR(255) NOT NULL, port INTEGER NOT NULL,
username VARCHAR(64) NOT NULL, password_enc TEXT NOT NULL, verify_tls BOOLEAN NOT NULL, created_at DATETIME NOT NULL,
online BOOLEAN, status_json TEXT NOT NULL, status_at DATETIME, last_error TEXT, last_backup_requested_at DATETIME,
use_tls BOOLEAN NOT NULL DEFAULT 1, group_id INTEGER, note TEXT, PRIMARY KEY (id), UNIQUE (name));
CREATE TABLE jobs (id INTEGER NOT NULL, device_id INTEGER, device_name VARCHAR(64) NOT NULL, type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL, finished_at DATETIME, PRIMARY KEY (id));
CREATE TABLE backups (id INTEGER NOT NULL, device_id INTEGER NOT NULL, requested_at DATETIME NOT NULL, status VARCHAR(16) NOT NULL,
key_binary VARCHAR(512), key_rsc VARCHAR(512), error TEXT, PRIMARY KEY (id),
FOREIGN KEY(device_id) REFERENCES devices (id) ON DELETE CASCADE);
CREATE TABLE device_groups (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, PRIMARY KEY (id), UNIQUE (name));
"""
def test_migration_replaces_numeric_ids(tmp_path):
"""БД старой схемы: числовые ID заменяются на dev_/grp_/job_/bkp_, ссылки и порядок сохраняются, повтор — без эффекта."""
path = tmp_path / "legacy.db"
con = sqlite3.connect(path)
con.executescript(LEGACY_DDL)
con.executemany("INSERT INTO device_groups VALUES (?, ?)", [(1, "Alpha"), (2, "Beta")])
for i, (name, grp) in enumerate((("r1", 1), ("r2", None)), start=1):
con.execute("INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, created_at, status_json, use_tls, group_id)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?)", (i, name, "10.0.0.%d" % i, 80, "u", "enc", 0, "2026-09-1%d 10:00:00.000000" % i, "{}", 0, grp))
# задачи: две по r1 (id 1), одна по уже удалённому устройству (id 99)
for i, dev in enumerate((1, 1, 99), start=1):
con.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?,?,?)", (i, dev, "n", "backup", "done", "ok", "2026-09-19 10:0%d:00.000000" % i, None))
con.execute("INSERT INTO backups VALUES (1, 1, '2026-09-19 10:00:00.000000', 'done', 'backups/r1/a.backup', 'backups/r1/a.rsc', NULL)")
con.execute("INSERT INTO backups VALUES (2, 99, '2026-09-19 10:05:00.000000', 'done', 'backups/gone/b.backup', NULL, NULL)")
con.commit()
con.close()
db.init_db(f"sqlite:///{path}")
con = sqlite3.connect(path)
assert con.execute("PRAGMA user_version").fetchone()[0] == 2
counts = {t: con.execute(f"SELECT count(*) FROM {t}").fetchone()[0] for t in ("device_groups", "devices", "jobs", "backups")}
assert counts == {"device_groups": 2, "devices": 2, "jobs": 3, "backups": 2}
alpha = con.execute("SELECT id FROM device_groups WHERE name='Alpha'").fetchone()[0]
r1, r2 = (con.execute("SELECT id, group_id FROM devices WHERE name=?", (n,)).fetchone() for n in ("r1", "r2"))
assert ids.is_id(r1[0], "dev") and r1[1] == alpha and r2[1] is None # связь устройство → группа сохранена
job_dev = [r[0] for r in con.execute("SELECT device_id FROM jobs ORDER BY id")]
assert job_dev[0] == job_dev[1] == r1[0] and job_dev[2] not in (r1[0], r2[0]) # удалённое устройство — свой стабильный ID
assert [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY created_at")] == [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY id")]
bk = con.execute("SELECT device_id, device_name FROM backups ORDER BY requested_at").fetchall()
assert bk[0] == (r1[0], "r1") and bk[1][0] == job_dev[2] and bk[1][1] == "gone" # имя — из ключа S3
every = [r[0] for t in ("device_groups", "devices", "jobs", "backups", "events") for r in con.execute(f"SELECT id FROM {t}")]
assert len(every) == len(set(every)) and all(ids.is_id(i) for i in every) # ни одного пересечения между таблицами
assert con.execute("SELECT type FROM events").fetchall() == [("system.migrated",)]
assert len(list(tmp_path.glob("legacy.db.bak-*"))) == 1 # копия БД перед миграцией
con.close()
db.init_db(f"sqlite:///{path}") # повторный запуск ничего не меняет
con = sqlite3.connect(path)
assert con.execute("SELECT count(*) FROM events").fetchone()[0] == 1 and len(list(tmp_path.glob("legacy.db.bak-*"))) == 1
con.close()
@pytest.mark.asyncio
async def test_events_link_entities(monkeypatch):
"""Журнал: у каждой записи свой ID, ссылка на ID сущности и актор; в журнал попадают только смены online/offline."""
events.set_actor("api")
d = devices.create_device("r1", "10.0.0.1", 80, "u", "p")
up = {"on": False}
def handler(r: httpx.Request) -> httpx.Response:
if not up["on"]:
raise httpx.ConnectError("refused")
data = {"/rest/system/resource": {"version": "7.24", "uptime": "1m", "board-name": "hAP"},
"/rest/system/routerboard": {"routerboard": "false"},
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.24", "latest-version": "7.24"}}
return httpx.Response(200, json=data.get(r.url.path, []))
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
await ops.poll_device(d.id)
await ops.poll_device(d.id) # повторный опрос без смены состояния — нового события нет
up["on"] = True
await ops.poll_device(d.id)
async def fake_backup(device_id):
return "готово"
monkeypatch.setitem(jobs.JOB_TYPES, "backup", fake_backup)
[job_id] = jobs.start_jobs("backup", [d.id])
await asyncio.sleep(0.3)
evs = events.list_events(device_id=d.id)
assert [e.type for e in reversed(evs)] == ["device.created", "device.offline", "device.online",
"job.created", "job.started", "job.done"]
assert all(ids.is_id(e.id, "evt") and e.actor == "api" and e.device_id == d.id for e in evs)
assert len({e.id for e in evs}) == len(evs) and [e.id for e in evs] == sorted((e.id for e in evs), reverse=True)
job_events = events.list_events(job_id=job_id)
assert {e.entity_id for e in job_events} == {job_id} and jobs.get_job(job_id).status == "done"
def test_events_api_and_id_validation():
"""API отдаёт журнал с ID; ID чужого типа и числовые ID дают 404; вход в UI попадает в журнал."""
with TestClient(create_app()) as c:
h = {"Authorization": "Bearer test-token"}
c.post("/login", data={"username": "admin", "password": "wrong"})
c.post("/login", data={"username": "admin", "password": "pw"})
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
assert ids.is_id(d["id"], "dev")
evs = c.get("/api/v1/events", headers=h, params={"entity_id": d["id"]}).json()
assert [e["type"] for e in evs] == ["device.created"] and evs[0]["actor"] == "api" and evs[0]["data"]["name"] == "r1"
assert c.get(f"/api/v1/events/{evs[0]['id']}", headers=h).json()["entity_id"] == d["id"]
auth = c.get("/api/v1/events", headers=h, params={"type": "auth"}).json()
assert sorted(e["type"] for e in auth) == ["auth.failed", "auth.login"]
assert "pw" not in json.dumps(auth) # пароли в журнал не попадают
assert c.get("/api/v1/devices/1", headers=h).status_code == 404
assert c.get(f"/api/v1/devices/{ids.new_id('job')}", headers=h).status_code == 404
assert c.get(f"/api/v1/jobs/{d['id']}", headers=h).status_code == 404
@pytest.mark.asyncio
async def test_backup_files_get_ids_and_deletion_is_recorded(monkeypatch):
"""Файлы бакета без метаданных получают bkp_ID (парой .backup/.rsc); пропавшие из бакета — помечаются удалёнными."""
items = [{"key": f"backups/r1/{s}", "size": 1, "last_modified": datetime(2026, 9, 10, 12, tzinfo=timezone.utc)}
for s in ("20260910-1.backup", "20260910-1.rsc")]
listing = {"items": items}
async def fake_list(device=None):
return listing["items"]
monkeypatch.setattr(s3, "list_backups", fake_list)
found, _ = await backups.search()
assert len({i["backup_id"] for i in found}) == 1 and ids.is_id(found[0]["backup_id"], "bkp") # одна копия = пара файлов
again, _ = await backups.search()
assert again[0]["backup_id"] == found[0]["backup_id"] # повторный вызов ID не меняет и дубликатов не создаёт
listing["items"] = []
await backups.search()
with session_scope() as s:
b = s.query(Backup).one()
assert b.deleted_at is not None and b.id == found[0]["backup_id"]
assert [e.type for e in events.list_events(entity_id=b.id)] == ["backup.deleted", "backup.imported"]
def _fill_events(n: int, *, age_days: int = 0) -> None:
with session_scope() as s:
for i in range(n):
s.add(Event(type="device.updated", entity_type="device", message=f"событие {i}", actor="system",
ts=now() - timedelta(days=age_days)))
def test_journal_rotation_by_age_and_count():
"""Ротация: по сроку, по числу записей (после неё в журнале ровно max_rows), 0 = без ограничения, валидация."""
settings.save_journal(0, 0)
_fill_events(5, age_days=100)
_fill_events(20)
assert settings.save_journal(30, 0) == {"retention_days": 30, "max_rows": 0} # срок 30 дней: старые 5 удалены
assert events.count(type_="device.updated") == 20
rotated = events.list_events(type_="journal.rotated")
assert len(rotated) == 1 and json.loads(rotated[0].data)["by_age"] == 5
_fill_events(150)
settings.save_journal(0, 100) # лимит 100: остаётся ровно 100 записей, удалены самые старые
assert events.count() == 100
newest = events.list_events(limit=1)[0]
assert newest.type == "journal.rotated" and json.loads(newest.data)["by_count"] > 0
assert events.rotate() == {"by_age": 0, "by_count": 0} # повторный запуск ничего не удаляет (нет «пилы»)
settings.save_journal(0, 0) # 0/0 — без ограничений
_fill_events(30)
assert events.rotate() == {"by_age": 0, "by_count": 0}
for bad in ((-1, 0), (0, 50), ("x", 0)):
with pytest.raises(ValueError):
settings.save_journal(*bad)
def test_journal_clear_requires_password_through_modal():
"""Очистка: только через окно с паролем пользователя; неверный пароль не очищает и ведёт к блокировке;
успех оставляет одну запись об очистке; в API очистки нет."""
htmx = {"HX-Request": "true"}
with TestClient(create_app()) as c:
h = {"Authorization": "Bearer test-token"}
c.post("/login", data={"username": "admin", "password": "pw"})
_fill_events(30)
before = events.count()
page = c.get("/ui/dialog/events-clear", headers=htmx).text
assert "Очистить журнал" in page and 'id="clear-confirm" disabled' in page # кнопка неактивна без пароля
assert c.post("/events/clear", data={"password": "pw"}).status_code == 400 # не через окно — отклонено
assert events.count() == before
r = c.post("/events/clear", data={"password": ""}, headers=htmx)
assert "Введите пароль" in r.text and events.count() == before
for left in (4, 3, 2, 1):
r = c.post("/events/clear", data={"password": "wrong"}, headers=htmx)
assert f"Осталось попыток: {left}" in r.text and "HX-Refresh" not in r.headers
r = c.post("/events/clear", data={"password": "wrong"}, headers=htmx) # 5-я неверная — блокировка
assert "Слишком много неверных попыток" in r.text
r = c.post("/events/clear", data={"password": "pw"}, headers=htmx) # верный пароль при блокировке не принимается
assert "Повторите через" in r.text and "HX-Refresh" not in r.headers
denied = events.list_events(type_="journal.clear_denied")
assert len(denied) == 5 and "wrong" not in json.dumps([e.data for e in denied]) # пароль в журнал не пишется
assert events.list_events(type_="journal.clear_locked")
security.reset_failures("admin") # окончание блокировки
r = c.post("/events/clear", data={"password": "pw"}, headers=htmx)
assert r.headers["HX-Refresh"] == "true"
left = events.list_events()
assert [e.type for e in left] == ["journal.cleared"] and left[0].actor == "ui:admin"
assert json.loads(left[0].data)["deleted"] > 30
assert c.delete("/api/v1/events", headers=h).status_code in (404, 405) # очистки через API нет
assert c.post("/api/v1/events/clear", headers=h, json={"password": "pw"}).status_code in (404, 405, 422)
def test_journal_page_filters_cursor_dialogs_and_settings():
"""Страница журнала: фильтры, «Показать ещё» по курсору, окно записи, сохранение настроек."""
htmx = {"HX-Request": "true"}
with TestClient(create_app()) as c:
h = {"Authorization": "Bearer test-token"}
c.post("/login", data={"username": "admin", "password": "pw"})
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
_fill_events(105)
page = c.get("/events").text
assert page.count('class="clickable"') == 100 and "Показать ещё 100" in page and "Показано 100 из" in page
last = re.findall(r'href="/events/(evt_[0-9a-f-]+)"', page)[-1]
more = c.get(f"/ui/events?before={last}&shown=100", headers=htmx).text
assert more.count('class="clickable"') >= 5 and 'hx-swap-oob="true"' in more and "Показать ещё" not in more
# фильтры: тип (группа), актор, устройство, поиск по ID
only_dev = c.get("/events?type=device&actor=api&device=" + d["id"]).text
assert only_dev.count('class="clickable"') == 1 and "device.created" in only_dev
assert c.get("/events?q=" + d["id"][-8:]).text.count('class="clickable"') >= 1
assert "Записей не найдено" in c.get("/events?q=нет-такого-текста").text
assert c.get("/api/v1/events", headers=h, params={"actor": "api"}).json()[0]["type"] == "device.created"
ev = events.list_events(entity_id=d["id"])[0]
dialog = c.get(f"/ui/dialog/event/{ev.id}", headers=htmx).text
shown = html.unescape(dialog) # Jinja экранирует кавычки в JSON, браузер показывает обычные
assert ev.id in shown and d["id"] in shown and '"name": "r1"' in shown and 'data-copy="' + ev.id in shown
assert c.get("/events/evt_bogus").status_code == 404 and c.get(f"/events/{ev.id}").status_code == 200
# настройки: ошибка остаётся в окне, успех — обновление страницы, значения видны в API
r = c.post("/events/settings", data={"retention_days": "10", "max_rows": "5"}, headers=htmx)
assert "Максимум записей" in r.text and "HX-Refresh" not in r.headers
r = c.post("/events/settings", data={"retention_days": "45", "max_rows": "5000"}, headers=htmx)
assert r.headers["HX-Refresh"] == "true"
assert c.get("/api/v1/events/settings", headers=h).json()["retention_days"] == 45
assert c.put("/api/v1/events/settings", headers=h, json={"retention_days": 0, "max_rows": 0}).json()["max_rows"] == 0
assert "ротация: без ограничений" in c.get("/events").text