IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
a846d30872
64 files changed
+4194
No files matched your search
@@ -0,0 +1,96 @@
|
||||
# IPAM Manager — backend (Python + PostgreSQL) и UI-админка
|
||||
|
||||
## Context
|
||||
Есть макеты «IPAM Manager» (страница 2 канваса): Обзор, Префиксы (дерево, VRF), Адреса в подсети,
|
||||
Организации, Операторы связи, Устройства (+ типы), Журнал, диалоги создания/редактирования.
|
||||
Проект пуст. Нужно спроектировать и реализовать API-first приложение: backend на Python с PostgreSQL,
|
||||
UI-админка — отдельный лёгкий фронт (без сборки), который только визуализирует ответы API.
|
||||
|
||||
Решения (согласованы): FastAPI + SQLAlchemy 2 + Alembic + psycopg3; UI — статический SPA (Alpine.js + fetch),
|
||||
раздаётся тем же приложением; auth — локальные пользователи + JWT; окружение — Docker Compose (postgres + app),
|
||||
venv в корне для тестов/линтеров.
|
||||
|
||||
## Доменная модель (PostgreSQL)
|
||||
Нативные типы `CIDR` / `INET` — сравнения, вложенность (`<<=`, `>>=`) и семейство (`family()`) считает БД.
|
||||
|
||||
| Таблица | Ключевые поля |
|
||||
|---|---|
|
||||
| `organizations` | name, short_name, inn (uniq), address, contact_person, phone, email, note |
|
||||
| `vrfs` | organization_id, name, route_target, note; uniq(org, name) |
|
||||
| `prefixes` | organization_id, vrf_id, prefix CIDR, description, status (active/reserved/deprecated), parent_id (авто по вложенности, можно задать), is_pool, note; uniq(vrf, prefix) |
|
||||
| `addresses` | prefix_id, address INET, status (assigned/reserved/deprecated), dns_name, description, device_id, note, updated_at; uniq(prefix, address); CHECK «адрес ∈ префикс» на уровне сервиса |
|
||||
| `isps` | name, organization_id, contract_number, hotline, note |
|
||||
| `isp_networks` | isp_id, cidr (несколько IP-блоков на оператора) |
|
||||
| `device_types` | name (uniq), is_default |
|
||||
| `devices` | name (hostname), device_type_id, mac, organization_id, note |
|
||||
| `users` | username (uniq), password_hash (argon2), role (admin/viewer), is_active |
|
||||
| `audit_log` | ts, user_id, entity_type, entity_id, entity_label, action (created/updated/deleted/assigned), diff JSONB |
|
||||
|
||||
Вычисляемое (не хранится): использование префикса (назначено/ёмкость), «свободные» адреса (в макете статус
|
||||
«Свободен» — пропуски в диапазоне), число префиксов/адресов у организации, число устройств у типа, число IP у устройства.
|
||||
Правила: VRF/тип нельзя удалить, пока используются (в макете кнопка disabled) → 409.
|
||||
|
||||
## API (`/api/v1`, OpenAPI на `/docs`)
|
||||
- `auth`: `POST /auth/login`, `GET /auth/me` (logout — на клиенте, токен короткоживущий).
|
||||
- `overview`: `GET /overview` — карточки (префиксов, VRF, адресов, использование, резерв), топ загрузки, последние изменения.
|
||||
- `organizations`, `isps`, `devices`, `device-types`, `vrfs`: CRUD; списки с `q`, `limit`, `offset`, фильтры (org, тип, vrf).
|
||||
- `prefixes`: CRUD; `GET /prefixes?org=&vrf=&status=&family=&q=` (дерево через parent_id + utilization);
|
||||
счётчики вкладок VRF; `GET /prefixes/{id}`.
|
||||
- `prefixes/{id}/addresses`: список (`status`, `q`, `limit`/`offset` — «Показать ещё 100»), `POST` назначить,
|
||||
`POST .../next` — автоназначение из пула (`is_pool`), сводка «назначено/резерв/свободно».
|
||||
- `addresses/{id}`: PATCH/DELETE.
|
||||
- `audit`: `GET /audit` (фильтры, пагинация) — источник «Последних изменений»; полный экран «Журнал» — вне этого этапа.
|
||||
- Единый формат ошибок `{code, message, fields}`; валидация CIDR/IP/MAC/FQDN в pydantic; 409 на дубли и пересечения.
|
||||
- Запись в `audit_log` — в сервисном слое в одной транзакции с изменением.
|
||||
|
||||
## Структура репозитория
|
||||
```
|
||||
app/ main.py config.py db.py security.py
|
||||
models/ schemas/ services/ api/v1/
|
||||
alembic/ (миграция 0001 — вся схема + seed: admin, типы устройств)
|
||||
web/ index.html, app.js, api.js, styles.css (IBM Plex, токены цвета из макетов)
|
||||
tests/ (минимум)
|
||||
docs/changes/001-ipam-backend/ PLAN.md, SUMMARY.md
|
||||
docker-compose.yml Dockerfile .env.example requirements.txt README.md venv/ (в .gitignore)
|
||||
```
|
||||
|
||||
## UI (web/)
|
||||
Экраны 1:1 с макетами: логин, Обзор, Префиксы (вкладки VRF, фильтры, «Управление VRF»), Адреса подсети,
|
||||
Организации, Операторы, Устройства («Управление типами»), модальные формы. Только `fetch` к `/api/v1`, JWT в
|
||||
`sessionStorage`; 401 → экран логина. Пункт «Журнал» — заглушка со ссылкой на `/audit` (по вопросу №1 из макета).
|
||||
|
||||
## Порядок работ (по артефактам из CLAUDE.md)
|
||||
0. Создать `docs/changes/001-ipam-backend/PLAN.md` (этот план) — до кода.
|
||||
1. Каркас: docker-compose (postgres:16 + app), Dockerfile, config, venv, Alembic.
|
||||
2. Модели + миграция 0001 + seed.
|
||||
3. Auth (login/JWT/роли; viewer — только чтение).
|
||||
4. Справочники: организации, VRF, операторы, типы, устройства.
|
||||
5. Префиксы и адреса: вложенность, использование, next-free, проверка «адрес ∈ префикс», пересечения в VRF.
|
||||
6. Обзор + audit.
|
||||
7. UI SPA.
|
||||
8. Тесты, обновить `README.md`, написать `SUMMARY.md`.
|
||||
|
||||
## Тесты (минимум, pytest + реальный Postgres из compose)
|
||||
1. Логин → защищённый эндпоинт (401 без токена, 200 с токеном).
|
||||
2. Префикс: дубль в VRF → 409; адрес вне префикса → 422.
|
||||
3. Использование префикса и next-free считаются верно.
|
||||
4. Удаление VRF/типа «в использовании» → 409.
|
||||
|
||||
## Проверка end-to-end
|
||||
`docker compose up -d --build` → `alembic upgrade head` отрабатывает автоматически → `pytest` зелёный →
|
||||
открыть `http://localhost:8000/` (UI), войти `admin`, пройти сценарий: организация → VRF → префикс →
|
||||
назначить адрес → увидеть его в Обзоре и в audit. `http://localhost:8000/docs` — Swagger.
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- VRF принадлежит организации (в макете «общий список для организации»).
|
||||
- Свободные адреса вычисляются, а не хранятся.
|
||||
- Экран «Журнал» с ротацией/очисткой (страница ROS Manager) в этот этап не входит — только запись и чтение audit.
|
||||
- Начальный пароль admin задаётся через `.env`, не хардкодится.
|
||||
|
||||
## Уточнения по итогам утверждения
|
||||
- План утверждён пользователем; сохранён в этом файле до начала кода.
|
||||
- Тесты выполняются против приложения, поднятого в контейнерах (docker compose). Учётные данные для тестов
|
||||
генерируются автоматически (случайные пароль admin и секрет JWT в `.env`, файл в .gitignore). Контейнеры локальные, без внешнего доступа.
|
||||
- Сборка и запуск вспомогательных инструментов (pytest, линтеры, alembic) — из `venv/` в корне проекта.
|
||||
- Обязательная сверка UI собранного приложения с макетами (все экраны и диалоги страницы «IPAM Manager»);
|
||||
расхождения устраняются до завершения работы, результат сверки фиксируется в SUMMARY.md.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Суммаризация: backend + UI IPAM Manager (изменение 001)
|
||||
|
||||
## Сделано
|
||||
- **Backend** (FastAPI): 9 групп эндпоинтов по плану — auth/JWT, обзор, организации, VRF, операторы, типы устройств,
|
||||
устройства, префиксы (дерево, использование, автоназначение), адреса, журнал изменений.
|
||||
- **БД**: PostgreSQL 16 в Docker Compose, схема — миграция Alembic `0001` (сгенерирована из моделей), нативные `CIDR`/`INET`.
|
||||
- **UI** (`web/`): все экраны и диалоги страницы «IPAM Manager» — Обзор, Префиксы (дерево, вкладки VRF, фильтры),
|
||||
Адреса подсети, Организации, Операторы, Устройства, диалоги префикса/адреса/VRF/организации/оператора/устройства/типов; плюс логин и простой «Журнал».
|
||||
- **Окружение**: `docker-compose.yml`, `Dockerfile`, `scripts/gen_env.py` (случайные учётные данные), `scripts/seed_demo.py`, `venv/` в корне.
|
||||
- **Документация**: `README.md`, `PLAN.md`, этот файл.
|
||||
|
||||
## Проверка
|
||||
- 4 автотеста (auth; валидация префикса/адреса; дерево, использование, автоназначение; запрет удаления используемых объектов) — против контейнеров, **4 passed**.
|
||||
- Сквозной сценарий UI в headless-браузере (вход/ошибка входа, организация, VRF, префикс, адреса, устройства, типы, операторы, журнал, выход) — пройден, ошибок JS в консоли нет
|
||||
(кроме ожидаемых 401/422 в негативных шагах).
|
||||
- **Сверка с макетами**: все 13 экранов/диалогов отрендерены рядом с макетом (1440 px) и просмотрены. Структура, сетка, размеры,
|
||||
цвета, шрифты, иконки, бейджи, состояния (наведение, зачёркнутое устройство, «Свободен») совпадают. Оставшиеся отличия — только данные
|
||||
(в макетах вымышленные значения и предзаполненные формы) и состояния, которых нет в статичных макетах (фокус поля, раскрытие меню).
|
||||
|
||||
## Осознанные отступления и допущения
|
||||
1. В макете «Префиксы» у `10.10.1.0/24` в колонке «Статус» стоит бейдж «95%» — похоже на дефект макета (это загрузка); реализовано «Активен», загрузка — в колонке «Использование».
|
||||
2. Ёмкость родительского префикса = сумма вложенных листьев (иначе `10.0.0.0/8` всегда «0%»); Обзор считает только IPv4.
|
||||
3. По умолчанию в дереве раскрыта первая ветка (как в макете), остальные свёрнуты.
|
||||
4. Экран «Журнал» для IPAM в макетах отсутствует — сделана простая таблица `audit_log`; ротация/очистка (страница ROS Manager) не входит.
|
||||
5. Порт приложения 8088 (8000 на машине занят).
|
||||
|
||||
## Не вошло / дальше
|
||||
Управление пользователями (создание viewer-ов), пагинация «Показать ещё» для организаций/устройств, импорт/экспорт, экран журнала с фильтрами.
|
||||
@@ -0,0 +1,51 @@
|
||||
# План: смена VRF у префикса и целостность «VRF ⊂ организация» (изменение 002)
|
||||
|
||||
## Контекст
|
||||
VRF — часть адресного плана организации. Сейчас:
|
||||
- уникальность VRF уже в пределах организации: `UNIQUE(organization_id, name)` — одно имя допустимо в разных организациях;
|
||||
- несколько префиксов организации могут быть в одном VRF (`UNIQUE(vrf_id, prefix)` — дубль CIDR запрещён только внутри VRF);
|
||||
- **смена VRF у существующего префикса невозможна**: нет ни поля в UI (`web/app.js`, `prefixDialog`), ни `vrf_id` в `PrefixUpdate` (`app/schemas.py`);
|
||||
- принадлежность VRF организации префикса проверяется только в коде (`create_prefix`), в БД гарантии нет.
|
||||
|
||||
Цель: разрешить смену VRF **только среди VRF той же организации**, сохранив целостность дерева, и закрепить правило на уровне БД.
|
||||
|
||||
## Правила
|
||||
1. Организация префикса неизменна; целевой VRF обязан принадлежать той же организации (иначе 422).
|
||||
2. Смена VRF переносит **префикс вместе со всем поддеревом** (вложенные по `parent_id`), чтобы дерево не «разрывалось» между VRF.
|
||||
3. Дубль `(vrf, prefix)` у любого переносимого префикса в целевом VRF → 409 с перечнем конфликтующих CIDR; перенос отменяется целиком.
|
||||
4. После переноса родитель корня поддерева пересчитывается в целевом VRF (самый узкий объемлющий); префиксы целевого VRF, лежащие внутри перенесённого, переподчиняются ему — та же логика, что при создании (выносим в общую функцию `attach_to_tree`).
|
||||
5. Адреса остаются у своих префиксов (`prefix_id` не меняется); использование/ёмкость пересчитываются автоматически.
|
||||
6. CIDR и родитель в окне редактирования по-прежнему не меняются (родитель определяется автоматически).
|
||||
|
||||
## Изменения
|
||||
**Backend**
|
||||
- `app/schemas.py`: `PrefixUpdate.vrf_id: int | None`.
|
||||
- `app/api/v1/prefixes.py`: в `update_prefix` — валидация правил 1–3, перенос поддерева, `attach_to_tree` (рефакторинг из `create_prefix`), запись в audit `{vrf: "old → new", moved: N}`.
|
||||
- Alembic `0002`:
|
||||
- `UNIQUE(id, organization_id)` на `vrfs` + составной FK `prefixes(vrf_id, organization_id) → vrfs(id, organization_id)` — БД не даст связать префикс с VRF чужой организации;
|
||||
- уникальность имени VRF без учёта регистра в пределах организации: индекс `(organization_id, lower(name))` вместо `UNIQUE(organization_id, name)`; перед применением — проверка на существующие дубли.
|
||||
|
||||
**UI (`web/app.js`)**
|
||||
- В окне редактирования префикса показать поле «VRF» (только VRF текущей организации, уже загружены в `S.vrfs`).
|
||||
- При выборе другого VRF — подсказка «Вместе с префиксом будет перенесено N вложенных» (N считается из `S.prefixes`); ошибки 409/422 — в стандартном баннере окна.
|
||||
- Вёрстка — из существующего окна создания (макета редактирования нет, отступлений от дизайна не появляется).
|
||||
|
||||
**Тесты (2 новых, всего 6)**
|
||||
1. Одноимённый VRF в двух организациях — ок; повтор в одной (в т.ч. другим регистром) — 409.
|
||||
2. Смена VRF: поддерево переехало, родитель пересчитан, дубль в целевом VRF — 409 без частичных изменений, VRF другой организации — 422.
|
||||
|
||||
## Порядок работ
|
||||
1. Рефакторинг `attach_to_tree` (поведение создания не меняется, тесты зелёные).
|
||||
2. Миграция `0002` (сначала проверка дублей, затем применение через `docker compose up`).
|
||||
3. `PrefixUpdate` + логика переноса.
|
||||
4. UI: поле VRF и подсказка.
|
||||
5. Тесты в контейнерах, прогон e2e-сценария UI, сверка окна с макетом «Новый префикс».
|
||||
6. Обновить `README.md` (правила VRF), написать `SUMMARY.md` в этой папке.
|
||||
|
||||
## Риски
|
||||
- Составной FK требует, чтобы `prefixes.organization_id` всегда совпадал с организацией VRF — уже так; миграция проверит данные и откажется применяться при расхождении.
|
||||
- Перенос крупного поддерева — одна транзакция; при 409 откат целиком.
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- Переносится всё поддерево, а не только выбранный префикс (альтернатива — запретить смену VRF у префиксов с вложенными).
|
||||
- Регистронезависимая уникальность имён VRF в организации допустима (`Lab` и `lab` считаются одним VRF).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Суммаризация: смена VRF у префикса и целостность «VRF ⊂ организация» (изменение 002)
|
||||
|
||||
## Сделано
|
||||
- **API:** `PATCH /prefixes/{id}` принимает `vrf_id`. Перенос — только в VRF той же организации (иначе 422), вместе со всем поддеревом;
|
||||
дубль CIDR в целевом VRF → 409 со списком конфликтов, откат целиком. Родитель пересчитывается в целевом VRF; логика подбора родителя
|
||||
вынесена в общую `attach_to_tree` (её же использует создание префикса). В журнал пишется `VRF: старый → новый` и число перенесённых.
|
||||
- **БД (миграция 0002):** `UNIQUE(id, organization_id)` на `vrfs` + составной FK `prefixes(vrf_id, organization_id) → vrfs`;
|
||||
имя VRF уникально в организации без учёта регистра (`uq_vrfs_org_lower_name`). Миграция сама проверяет данные на дубли/расхождения.
|
||||
Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`.
|
||||
- **UI:** в окне редактирования префикса появилось поле «VRF» (только VRF текущей организации), подсказка «Вместе с префиксом будет
|
||||
перенесено вложенных: N» при выборе другого VRF; ошибки 409/422 — в баннере окна.
|
||||
- **Тесты:** +2 (всего 6, все проходят против контейнеров): уникальность имён VRF по организациям; перенос поддерева (409 / 422 / успех).
|
||||
|
||||
## Найдено и исправлено попутно
|
||||
- Дубликат при создании (организация, VRF, тип, устройство, адрес, префикс) приводил к **500** вместо 409: ошибка уникальности возникала на `flush`,
|
||||
минуя обработку. Добавлен `services.flush` с тем же переводом в 409 и глобальный обработчик `IntegrityError` как страховка.
|
||||
- Меню действий строки оставалось на экране под открытым диалогом после выбора пункта.
|
||||
|
||||
## Ограничения
|
||||
- В UI кнопка «⋯» есть только у листовых префиксов (как в макете), поэтому перенос целого поддерева через интерфейс недоступен — только через API;
|
||||
подсказка про вложенные в UI готова и появится, если добавить действие у родительских префиксов.
|
||||
|
||||
## Дополнение: локальные шрифты
|
||||
IBM Plex Sans (400/500/600) и Mono (400/500) в формате woff2 (latin + cyrillic, ~150 КБ всего) лежат в `web/fonts/`, подключены через `fonts/fonts.css`
|
||||
(`@font-face` с `unicode-range`); ссылка на fonts.googleapis.com удалена. Проверено: при загрузке UI нет ни одного запроса за пределы приложения.
|
||||
|
||||
## Дополнение: выпадающие списки в диалогах
|
||||
- Баг: в окне «Редактировать префикс» на экране адресов список VRF был пуст (VRF загружались только на экране «Префиксы») — теперь запрашиваются и на экране адресов.
|
||||
- Нативный `<select>` во всех диалогах (VRF, статус, родитель, организация, тип, устройство) заменён на собственный выпадающий список в стиле меню строк:
|
||||
скруглённая карточка с тенью, подсветка выбранного и наведения, значение хранится в скрытом поле; закрытие по клику вне списка и по Esc
|
||||
(диалог при этом не закрывается), навигация стрелками ↑/↓. Это также ближе к макетам, где поля выбора — кнопки с шевроном.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Журнал: поиск по событиям, ротация, очистка и UI (изменение 003)
|
||||
|
||||
## Context
|
||||
Сейчас `audit_log` — сырая таблица (`ts, username, entity_type, entity_id, entity_label, action, diff`), экран «Журнал» — простая таблица
|
||||
без фильтров и без управления размером; журнал растёт бесконечно. В макетах канваса (страница «ROS Manager», листы Journal / JournalEntry /
|
||||
JournalSettings / JournalClear) уже нарисован целевой журнал: фильтры и поиск, окно записи, настройки ротации, очистка с паролем.
|
||||
Цель — реализовать это для IPAM (backend + UI), адаптировав поля под IPAM. Решения из блока «Журнал: вопросы для ревью» канваса берём как
|
||||
принятые: короткий ID 8 символов; «Показать ещё 100»; ротация по умолчанию 90 дней и 100 000 записей (0 — без ограничения);
|
||||
очистка по паролю пользователя, 5 неверных попыток за 10 минут → блокировка на 10 минут; настройки ротации сохраняются без пароля;
|
||||
строка таблицы целиком кликабельна и открывает окно записи; «Очистить журнал» — белая кнопка с красным текстом.
|
||||
|
||||
## Модель данных (миграция 0003)
|
||||
- `audit_log`: `+uid UUID` (default `gen_random_uuid()`, уникальный; в UI `evt_<uuid>`, короткий вид — первые 8 символов),
|
||||
`+message TEXT` (человекочитаемое: «Префикс 10.30.0.0/24 создан», для изменений — с перечнем полей); бэкфилл существующих строк.
|
||||
Тип события = `<entity_type>.<action>` (вычисляется, отдельной колонки нет). Актор = `username` для `system`/`anonymous`, иначе `ui:<username>`.
|
||||
- `app_settings(key PK, value JSONB)` — ключ `journal` = `{retention_days: 90, max_entries: 100000}` (создаётся с дефолтами).
|
||||
- `clear_attempts(id, user_id FK, ts)` — неудачные попытки подтверждения пароля при очистке (счётчик и блокировка в БД, переживают рестарт).
|
||||
- Индексы: `(entity_type, action)`; поиск по сообщению/ID — `ILIKE` (при лимите 100 000 записей достаточно; `pg_trgm` — при необходимости позже).
|
||||
|
||||
## Backend
|
||||
Новый `app/api/v1/journal.py` (перенос `/audit` из `overview.py`), `app/rotation.py`.
|
||||
- `GET /audit` — фильтры `event_type`, `entity_type`, `actor`, `date_from`, `date_to` (UTC, включительно), `q` (message / entity_label / начало uid),
|
||||
`limit`, `offset`; сортировка `id DESC`. Ответ: `items[{uid, short_id, ts, event_type, entity_type, entity_id, entity_label, actor, message, diff}]`, `total`.
|
||||
- `GET /audit/summary` — `total`, `oldest_ts`, `retention_days`, `max_entries` (для строки «1 284 записи · старейшая … · ротация …»).
|
||||
- `GET /audit/facets` — списки значений для фильтров: типы событий, акторы, сущности.
|
||||
- `GET /audit/{uid}` — запись целиком (окно записи).
|
||||
- `GET|PUT /journal/settings` — чтение всем, запись только admin; валидация: целые ≥ 0 (дни ≤ 3650, записей ≤ 10 000 000); после сохранения — немедленная ротация.
|
||||
- `POST /journal/clear {password}` — только admin: проверка пароля текущего пользователя (argon2); неверный → 403 `{attempts_left}`;
|
||||
5 неверных за 10 минут → 429 `{retry_after_seconds}`; успех: удалить все записи и в той же транзакции записать `journal.cleared` (кто, сколько удалено).
|
||||
Обработчик ошибок расширяется: `detail` может быть dict (доп. поля в тело ответа).
|
||||
- Ротация (`rotate(db)`): удаляет записи старше `retention_days` и самые старые сверх `max_entries`; при удалении >0 пишет `journal.rotated`
|
||||
(`system`, кол-во и причина). Запуск: фоновая задача раз в час (asyncio в `lifespan`, работа в потоке) и при сохранении настроек;
|
||||
защита от параллельного запуска в нескольких репликах — `pg_try_advisory_lock`.
|
||||
- Новые события в журнал: `auth.login`, `auth.failed` (`anonymous`), `journal.settings_updated`, `journal.cleared`, `journal.rotated`;
|
||||
`services.audit()` получает генерацию `message` и `uid`.
|
||||
- Обратная совместимость: поля `/audit`, используемые Обзором (`entity_label`, `action`, `username`, `ts`), сохраняются.
|
||||
|
||||
## UI (`web/app.js`, `web/styles.css`) — по макетам Journal*
|
||||
- Экран «Журнал»: заголовок + строка «N записей · старейшая <дата> · ротация: 90 дней / 100 000 записей»; кнопки «Обновить», «Настройки»,
|
||||
«Очистить журнал» (admin; для viewer скрыты/неактивны). Фильтры: «Тип», «Актор», «Сущность» (выпадающие списки в стиле меню — уже есть `filterBtn`),
|
||||
период «с … по …» (`input type=date`), поиск «Сообщение или ID» (debounce, как в остальных экранах). Таблица: Время (UTC, с секундами), Тип (бейдж),
|
||||
Сущность (тип · метка), Сообщение, Актор, ID записи (8 симв., моно); «Показать ещё 100», «Показано X из N записей».
|
||||
- Окно записи (640 px): ID с кнопкой «Копировать» (fallback через `execCommand`, т.к. по http `navigator.clipboard` недоступен), время, тип, актор, сущность, сообщение, блок «Данные» (JSON `diff`).
|
||||
- Окно «Настройки журнала» (560 px): текущее число записей и старейшая, поля «Хранить записи, дней» и «Максимум записей», пояснение о ротации.
|
||||
- Окно «Очистить журнал» (540 px): предупреждение, поле пароля, кнопка неактивна до ввода; состояния «Неверный пароль. Осталось попыток: N» и блокировка «Повторите через N мин.» (поле отключено).
|
||||
- Бейджи типов: created — синий, assigned — зелёный, updated — янтарный, deleted/auth.failed — красный, остальные — серый.
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать этот план в `docs/changes/003-journal-search-rotation/PLAN.md` (требование проекта).
|
||||
1. Миграция 0003 + модели; бэкфилл `message`/`uid`.
|
||||
2. `services.audit()` (message, uid) и новые события (`auth.*`).
|
||||
3. `journal.py`: список/поиск/summary/facets/запись; перенос `/audit`.
|
||||
4. Настройки, ротация (`rotation.py`, фоновая задача, advisory lock), очистка с блокировкой.
|
||||
5. UI: экран, окна записи/настроек/очистки.
|
||||
6. Тесты, прогон сценария UI, сверка окон с макетами, README, SUMMARY.
|
||||
|
||||
## Тесты (+3, всего 9; против контейнеров, свои учётные данные из `.env`)
|
||||
1. Поиск и фильтры: создать префикс → находится по `q` (метка, начало uid), по `event_type`, `actor`; `date_from` в будущем → пусто; `/audit/{uid}`.
|
||||
2. Ротация: запись с `ts` старше срока (вставка через БД по DSN из `.env`) и превышение `max_entries` удаляются при `PUT /journal/settings`;
|
||||
появляется `journal.rotated`; настройки в конце восстанавливаются к 90/100 000.
|
||||
3. Очистка: отдельный временный пользователь (вставка в БД, удаляется после теста, чтобы не блокировать `admin`): неверный пароль → 403 с `attempts_left`,
|
||||
5 неверных → 429; успешную очистку в общей базе не гоняем — она проверяется вручную в сценарии UI на сбрасываемой базе.
|
||||
|
||||
## Проверка end-to-end
|
||||
`docker compose up -d --build` (миграции автоматически) → `pytest` → сценарий в браузере: создать/изменить объекты → найти событие по тексту,
|
||||
типу, актору и периоду → открыть запись и скопировать ID → сменить ротацию на малый лимит и увидеть усечение и запись `journal.rotated` →
|
||||
очистка: неверный пароль (счётчик попыток), блокировка, успешная очистка → в журнале одна запись `journal.cleared`; после этого база пересоздаётся и заполняется `seed_demo.py`.
|
||||
Сверка окон записи/настроек/очистки с макетами Journal* (отличия шапки и названий — из-за другого приложения, ROS Manager).
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- Запись `journal.rotated` создаётся только когда что-то удалено (иначе каждый час по записи).
|
||||
- Смена регистра/формата: поиск `q` без учёта регистра, подстрока в сообщении и метке, префикс для ID.
|
||||
- Записи `auth.failed` не ограничиваются отдельно; рост от подбора паролей сдерживает ротация.
|
||||
- Экспорт журнала (CSV) и права viewer на очистку/настройки не входят.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Суммаризация: журнал — поиск, ротация, очистка, UI (изменение 003)
|
||||
|
||||
## Сделано
|
||||
- **БД (миграция 0003):** `audit_log` + `uid` (UUID) и `message` (бэкфилл существующих записей), индекс `(entity_type, action)`;
|
||||
таблицы `app_settings` (настройки ротации, по умолчанию 90 дней / 100 000 записей) и `clear_attempts` (неудачные попытки очистки).
|
||||
Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`.
|
||||
- **API:** `GET /audit` с поиском и фильтрами (текст/ID, тип события, сущность, актор, период UTC, «Показать ещё»), `/audit/summary`, `/audit/facets`,
|
||||
`/audit/{uid}`; `GET|PUT /journal/settings`; `POST /journal/clear` (пароль, 5 попыток / блокировка 10 минут).
|
||||
- **Ротация:** `app/rotation.py` — по сроку и по количеству; фоновая задача раз в час и сразу при сохранении настроек; PG advisory lock;
|
||||
сводная запись `journal.rotated` (итог после ротации не превышает лимит). Экранирование спецсимволов LIKE в поиске.
|
||||
- **События:** добавлены `session.login` / `session.failed`, `journal.settings_updated` / `rotated` / `cleared`; у каждой записи человекочитаемое сообщение.
|
||||
- **UI:** экран «Журнал» по макету Journal (строка «N записей · старейшая … · ротация …», фильтры Тип/Актор/Сущность/период/поиск, таблица, «Показать ещё 100»),
|
||||
окно записи (ID с копированием, данные JSON), окно «Настройки журнала», окно «Очистить журнал» (кнопка неактивна без пароля, счётчик попыток, блокировка).
|
||||
Настройки и очистка видны только admin.
|
||||
|
||||
## Проверка
|
||||
- 9 автотестов (3 новых: поиск и фильтры; ротация по сроку и по количеству; очистка — неверный пароль и блокировка на временном пользователе) — **9 passed** (дважды подряд).
|
||||
- Сценарий UI в браузере: фильтры, поиск, период, окно записи, копирование, настройки, ротация до 50 записей, неверный пароль → счётчик → блокировка,
|
||||
успешная очистка (остаётся одна запись `journal.cleared`), ошибок JS нет. Успешная очистка проверена на пересозданной БД; после проверки БД снова заполнена демо-данными.
|
||||
- Сверка с макетами Journal / JournalEntry / JournalSettings / JournalClear (они нарисованы для ROS Manager): сетка, размеры, цвета, состояния совпадают;
|
||||
отличия — предметные: колонка «Сущность» вместо «Устройство», типы событий IPAM.
|
||||
|
||||
## Допущения и ограничения
|
||||
- Запись `journal.rotated` создаётся только если что-то удалено.
|
||||
- Формат поля даты (`input type=date`) зависит от локали браузера (в макете — ISO).
|
||||
- Записи `auth.failed` отдельно не ограничиваются; рост от подбора паролей сдерживает ротация.
|
||||
- Экспорт журнала и управление пользователями не входят.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Журнал аудита: IP-адрес актора и метаданные запроса (изменение 004)
|
||||
|
||||
## Context
|
||||
В журнале видно, *кто* и *что* сделал (`actor`, `message`), но не *откуда*. Нужно фиксировать IP-адрес, с которого пользователь (в т.ч. admin)
|
||||
выполнил изменение, и сопутствующие метаданные, и показывать их в UI. Сейчас `audit()` (`app/services.py`) не знает о запросе:
|
||||
вызывается из ~30 мест, IP нигде не сохраняется.
|
||||
|
||||
Что выяснено (read-only проверка):
|
||||
- Приложение публикуется через Docker (`0.0.0.0:8088 → 172.x:8000`). Запрос с LAN-адреса машины приходит с реальным источником (`192.168.5.9`),
|
||||
с удалённых хостов — тоже (DNAT сохраняет источник); запрос через `127.0.0.1` приходит как адрес шлюза docker-сети (`172.29.0.1`) —
|
||||
это особенность `docker-proxy` для loopback, не ошибка приложения. Отдельная прокси-настройка для получения реального IP не нужна.
|
||||
- Если позже перед приложением появится reverse-proxy с TLS (как предлагалось), реальный IP будет в `X-Forwarded-For`, но доверять заголовку можно
|
||||
только от известных прокси — иначе IP подделывается. Поэтому закладываем настройку доверенных прокси (по умолчанию пусто).
|
||||
|
||||
Заодно (жалоба «не вижу обновлений»): сервер отдаёт актуальные `app.js`/`styles.css` (проверено по LAN-адресу), но без `Cache-Control` —
|
||||
браузер может держать старую версию по эвристике. Включаем ревалидацию (`Cache-Control: no-cache` + уже есть ETag).
|
||||
|
||||
## Решение
|
||||
**Каждая запись журнала, созданная в рамках HTTP-запроса, получает `client_ip` и `meta`**; записи, созданные системой (ротация, миграции) — без IP.
|
||||
Это покрывает и действия admin, и неудачные входы (`anonymous`, самый ценный случай для расследования), и любые будущие роли.
|
||||
|
||||
## Модель данных (миграция 0004)
|
||||
- `audit_log`: `+client_ip INET NULL` (индекс), `+meta JSONB NULL` — расширяемые метаданные запроса:
|
||||
`{"user_agent": "…", "method": "POST", "path": "/api/v1/prefixes", "request_id": "…"}` (User-Agent ≤ 255 символов; только путь, без query-строки).
|
||||
- Существующие записи остаются с `NULL` (в UI «—»). Даунгрейд удаляет колонки.
|
||||
|
||||
## Backend
|
||||
- `app/request_context.py` (новый): `ContextVar` с метаданными текущего запроса и ASGI-middleware `RequestContextMiddleware`:
|
||||
определяет IP клиента, User-Agent, метод, путь, генерирует `request_id` (возвращается в заголовке `X-Request-ID`).
|
||||
`resolve_client_ip(peer, x_forwarded_for, trusted)` — чистая функция: `X-Forwarded-For` учитывается **только если сокет-пир входит в `TRUSTED_PROXIES`**
|
||||
(берётся первый недоверенный адрес справа); иначе — адрес пира. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) нормализуется в IPv4.
|
||||
- `app/config.py`: `trusted_proxies: str = ""` (CIDR через запятую); `.env.example`, `docker-compose.yml` (пробросить `TRUSTED_PROXIES`).
|
||||
- `app/services.py::audit()`: читает контекст запроса и заполняет `client_ip`/`meta`; для `user=SYSTEM` (ротация, в т.ч. запущенная из запроса на сохранение настроек) IP не пишется.
|
||||
Сигнатура `audit()` не меняется — 30 мест вызова не трогаем.
|
||||
- `app/main.py`: подключить middleware; для статики — `Cache-Control: no-cache` (подкласс `StaticFiles`).
|
||||
- `GET /audit`: новый фильтр `client_ip` (точный IP или подсеть CIDR — `inet <<=`); текстовый поиск `q` дополнительно ищет по началу IP.
|
||||
`AuditOut` (+`from_row`): поля `client_ip: str | None`, `meta: dict | None`. Остальные поля/эндпоинты без изменений (Обзор не затронут).
|
||||
|
||||
## UI (`web/app.js`, `web/styles.css`)
|
||||
- Таблица журнала: новая колонка «IP-адрес» (моно 13 px, «—» если нет) — сетка `150px 168px 150px 1fr 110px 130px 96px`; поиск: «Сообщение, ID или IP».
|
||||
- Окно записи: строки «IP-адрес» (с кнопкой «Копировать») , «User-Agent», «Запрос» (`POST /api/v1/prefixes`); блок «Данные» остаётся для `diff`.
|
||||
- Отступление от макета Journal (он без IP) — сознательное, по запросу пользователя; остальная вёрстка не меняется.
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать план в `docs/changes/004-audit-client-ip/PLAN.md`.
|
||||
1. Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade).
|
||||
2. `request_context.py`, конфиг, middleware, `audit()`.
|
||||
3. `/audit`: поле, фильтр, поиск; `Cache-Control` для статики.
|
||||
4. UI: колонка и строки окна записи.
|
||||
5. Тесты, проверка через LAN-адрес и в браузере, README, SUMMARY.
|
||||
|
||||
## Тесты (+2, всего 11; против контейнеров)
|
||||
1. Запись IP: действие через API с заголовком `User-Agent: ipam-tests/1.0` → в `/audit` у записи есть валидный `client_ip` и `meta.user_agent`/`method`/`path`;
|
||||
фильтр `client_ip` находит запись, чужой IP/подсеть — нет; поддельный `X-Forwarded-For: 203.0.113.9` от недоверенного пира **не** попадает в журнал;
|
||||
у записи ротации (`system`) `client_ip = null`.
|
||||
2. Юнит-тест `resolve_client_ip` (без контейнеров): недоверенный пир игнорирует XFF; доверенный — берёт первый недоверенный справа; IPv4-mapped нормализуется.
|
||||
|
||||
## Проверка end-to-end
|
||||
`docker compose up -d --build` → `pytest` → запрос к API через `http://192.168.5.9:8088` (реальный источник) и через `127.0.0.1` (адрес шлюза docker) →
|
||||
в UI «Журнал» видна колонка IP, окно записи показывает IP/User-Agent/запрос, поиск по IP находит запись → `curl -I` статики показывает `Cache-Control: no-cache`.
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- IP пишется для всех действий из HTTP-запросов (не только admin) — иначе журнал неоднородный; для system — пусто.
|
||||
- Запросы с самой машины через `127.0.0.1` показывают адрес шлюза docker-сети; это поведение Docker и документируется в README.
|
||||
- IP — персональные данные: срок хранения регулируется существующей ротацией журнала; отдельная обезличка не делается.
|
||||
- За NAT/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Суммаризация: IP-адрес актора и метаданные запроса в журнале (изменение 004)
|
||||
|
||||
## Сделано
|
||||
- **БД (миграция 0004):** `audit_log` + `client_ip INET` (индекс) и `meta JSONB` (`user_agent`, `method`, `path`, `request_id`). Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`.
|
||||
- **Backend:** `app/request_context.py` — ASGI-middleware кладёт метаданные запроса в `ContextVar`, `audit()` читает их (30 мест вызова не менялись);
|
||||
заголовок `X-Request-ID` в ответах. `X-Forwarded-For` принимается только от прокси из `TRUSTED_PROXIES` (по умолчанию пусто, справа налево — первый недоверенный адрес), IPv4-mapped нормализуется.
|
||||
Системные события (ротация, даже запущенная из запроса) пишутся без IP.
|
||||
- **API:** `AuditOut.client_ip` / `meta`; `GET /audit?client_ip=` (IP или CIDR, 422 на мусор); `q` ищет и по началу IP.
|
||||
- **UI:** колонка «IP-адрес» в таблице журнала (сетка `… 110px 130px 96px`), поиск «Сообщение, ID или IP», в окне записи — IP (с копированием), «Запрос», «User-Agent».
|
||||
- **Кэш статики:** `Cache-Control: no-cache` (ревалидация по ETag) — браузер больше не держит устаревший `app.js`/`styles.css` после обновления
|
||||
(ответ на «не вижу обновлений»; для уже закэшированной версии один раз нужен Ctrl+F5).
|
||||
|
||||
## Проверка
|
||||
- 11 автотестов (2 новых: запись IP/метаданных, фильтр по IP и подсети, игнорирование поддельного `X-Forwarded-For`, пустой IP у системных событий; юнит-тест разбора цепочки прокси) — **11 passed**.
|
||||
- Через LAN-адрес `192.168.5.9:8088`: создание организации и неудачный вход записались с `client_ip = 192.168.5.9`, User-Agent и путём; фильтр `192.168.5.0/24` и поиск `192.168.5` находят записи; UI — колонка и окно записи проверены в браузере, ошибок JS нет.
|
||||
|
||||
## Допущения и ограничения
|
||||
- IP пишется для всех действий из HTTP-запросов (admin и прочие роли, неудачные входы — как `anonymous`); старые записи — «—».
|
||||
- Через `127.0.0.1` Docker подставляет адрес шлюза сети (`172.x.0.1`) — особенность docker-proxy, не ошибка приложения.
|
||||
- За NAT или прокси виден адрес ближайшего недоверенного узла, а не «настоящего» клиента; с reverse-proxy нужно задать `TRUSTED_PROXIES`.
|
||||
- IP — персональные данные: срок хранения определяется ротацией журнала.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Кликабельные строки во всех реестрах UI (изменение 005)
|
||||
|
||||
## Context
|
||||
В журнале аудита строка таблицы целиком кликабельна (`.tr.clickable`, `data-action="jr-open"`, курсор-указатель, подсветка при наведении).
|
||||
В остальных реестрах кликабельно только отдельное значение: на «Префиксах» — сам CIDR (ссылка) у листовых строк и шеврон у родительских.
|
||||
Это неудобно: попадать нужно в узкую цель. Задача — сделать строки кликабельными по единому паттерну журнала во всех реестрах
|
||||
(решения пользователя: родитель → свернуть/развернуть ветку, дочерний (лист) → открыть адреса; распространить на все реестры).
|
||||
|
||||
## Поведение по экранам (`web/app.js`)
|
||||
| Экран | Клик по строке | Уже работающие элементы (сохраняются) |
|
||||
|---|---|---|
|
||||
| Префиксы — лист (нет вложенных) | открыть адреса подсети (`#/prefixes/<id>`) | ссылка-CIDR, меню «⋯» |
|
||||
| Префиксы — родитель (есть вложенные) | свернуть/развернуть ветку | шеврон |
|
||||
| Организации | открыть префиксы организации (выбрать её и перейти на «Префиксы») | ссылки «название» и «число префиксов», меню «⋯» |
|
||||
| Операторы | окно редактирования оператора | ссылка на организацию, меню «⋯» |
|
||||
| Устройства | окно редактирования устройства | ссылка «число IP», меню «⋯» |
|
||||
| Адреса подсети — занятый/резерв/устаревший | окно редактирования адреса | меню «⋯» |
|
||||
| Адреса подсети — «Свободен» | окно «Назначить адрес» с подставленным IP | меню «⋯» |
|
||||
| Журнал | без изменений (уже так) | — |
|
||||
Не входят: таблицы на «Обзоре» (это сводка, не реестры) — при желании отдельной доработкой.
|
||||
|
||||
## Реализация
|
||||
- Строка получает `class="tr hoverable clickable"` + `data-action="<действие>"` + `data-id`/`data-ip` (как `jr-open`). Новые действия переиспользуют существующие
|
||||
обработчики: `pfx-open` (навигация), `toggle` (уже есть), `org-open` (`store.orgId = id; location.hash = "#/prefixes"`), `isp-edit` → `ispDialog`, `dev-edit` → `deviceDialog`,
|
||||
`adr-edit` → `addressDialog(a)`, `adr-assign` → `addressDialog(null, ip)`; данные строки берутся из `S.rows`/`S.page.items` через существующий `rowById`.
|
||||
- **Защита от лишних срабатываний** в едином глобальном обработчике клика (`document.addEventListener("click")`): действие строки не выполняется, если клик
|
||||
пришёл из `a[href]`, `button`, `input`/`label`/`select`/`textarea`, из всплывающего меню `.pop`, при нажатых Ctrl/Cmd/Shift (открытие ссылки в новой вкладке),
|
||||
при выделенном тексте (`getSelection()`), а также если в этот момент было открыто меню (тогда клик лишь закрывает меню). Вложенные элементы с собственным `data-action`
|
||||
(шеврон, «⋯», пункты меню) по-прежнему выигрывают у строки — берётся ближайший `[data-action]`.
|
||||
- Клавиатура: строки с вложенными элементами управления не делаем `role="button"` (вложенные интерактивные элементы — a11y-антипаттерн); доступ с клавиатуры остаётся
|
||||
через ссылку и меню «⋯» в строке. Шеврону добавляем `aria-expanded`.
|
||||
- Стили: `.tr.clickable{cursor:pointer}` и подсветка `.tr.hoverable:hover` уже есть; вёрстка строк, размеры и цвета не меняются (макеты не затрагиваются).
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать план в `docs/changes/005-clickable-rows/PLAN.md`.
|
||||
1. Строки и действия на 5 экранах + защита в глобальном обработчике.
|
||||
2. Проверка сценарием в браузере (Playwright, как в прошлых доработках) и регрессия вёрстки (сравнение скриншотов со сверкой макетов — без изменений).
|
||||
3. README (строка про поведение), `SUMMARY.md`.
|
||||
|
||||
## Проверка
|
||||
Сценарий в браузере на `http://192.168.5.9:8088`: клик по пустому месту строки — лист «Префиксов» открывает адреса, родитель сворачивает/разворачивает; Организации →
|
||||
префиксы нужной организации; Операторы/Устройства/Адреса → окно редактирования; свободный адрес → «Назначить адрес» с этим IP; клик по «⋯», шеврону, пункту меню, ссылке
|
||||
не вызывает действие строки дважды; Ctrl+клик по ссылке открывает новую вкладку без действия строки; выделение текста мышью не открывает диалог;
|
||||
клик при открытом меню только закрывает его. Автотесты бэкенда (11) остаются зелёными — backend не меняется; новых pytest-тестов не добавляем
|
||||
(правило проекта — минимум тестов, изменение чисто интерфейсное).
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- У родительской строки нельзя открыть адреса, назначенные прямо на неё (как и сейчас) — только раскрыть ветку.
|
||||
- Клик по строке организации ведёт в её префиксы (это уже основной сценарий: название и счётчик были ссылками туда), а не в окно редактирования; редактирование — через «⋯».
|
||||
@@ -0,0 +1,23 @@
|
||||
# Суммаризация: кликабельные строки во всех реестрах (изменение 005)
|
||||
|
||||
## Сделано
|
||||
Изменён только UI (`web/app.js`); backend и БД не менялись.
|
||||
- Строки получают `clickable` + `data-row`/`data-action`, как в журнале: «Префиксы» — лист → открыть адреса, родитель → свернуть/развернуть ветку;
|
||||
«Организации» → префиксы организации; «Операторы» / «Устройства» → окно редактирования; «Адреса» — занятый → редактирование, свободный → «Назначить адрес» с подставленным IP.
|
||||
Шеврону добавлен `aria-expanded`.
|
||||
- В едином глобальном обработчике клика действие строки не выполняется, если клик по ссылке, кнопке, полю формы или всплывающему меню, при Ctrl/Cmd/Shift/Alt,
|
||||
при выделенном тексте; если открыто меню — клик по строке только закрывает его. Вложенные `data-action` (шеврон, «⋯», пункты меню) по-прежнему приоритетнее строки.
|
||||
- Вёрстка, размеры и цвета не менялись (стили `.tr.clickable`/`.hoverable` уже существовали).
|
||||
|
||||
## Проверка
|
||||
- Сценарий в браузере по `http://192.168.5.9:8088` (15 проверок, все пройдены, ошибок JS нет): клик по строке каждого реестра; родитель сворачивается/разворачивается;
|
||||
шеврон срабатывает один раз; клик по «⋯» и пунктам меню не запускает действие строки; при открытом меню клик по строке лишь закрывает его; Shift-клик и выделение текста игнорируются;
|
||||
Ctrl+клик по ссылке открывает новую вкладку, текущая не навигирует; ссылка организации в «Операторах» работает без открытия диалога.
|
||||
- 11 автотестов backend — зелёные. Новых pytest-тестов не добавлено (изменение интерфейсное; правило проекта — минимум тестов).
|
||||
- Повторная сверка со скриншотами макетов не выполнялась: в БД, изменённой при ручном тестировании (удалены часть префиксов демо-данных), нужные для неё объекты отсутствуют;
|
||||
разметка строк изменилась только атрибутами.
|
||||
|
||||
## Ограничения
|
||||
- Клавиатура: строки не делаем `role="button"` (внутри есть ссылки и кнопки); доступ остаётся через ссылку и меню «⋯».
|
||||
- У родительского префикса нельзя открыть адреса, назначенные прямо на него, — только раскрыть ветку.
|
||||
- Таблицы «Обзора» (сводка) не менялись.
|
||||
Reference in new issue
Block a user