Files
ros_control/docs/changes/017-events-ui-rotation/plan.md
T

59 lines
12 KiB
Markdown
Raw Normal View History

# План: 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`/копии тома).