Уникальные 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

@@ -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`; блокировка попыток хранится в памяти процесса (сбрасывается перезапуском).
- Уменьшение срока/лимита в настройках сразу удаляет старые записи (пароль для настроек по ТЗ не требуется); действие фиксируется в журнале.
- Очистка необратима (кроме восстановления из копии БД/тома). Боевой журнал при проверке не очищался.