Files
ayurishchevandClaude Opus 5.5 5f7bd6588a Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD
Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
  фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
  перезагрузка; API POST /devices/{id}/firmware/downgrade и
  /batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.

Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:04:51 +03:00

227 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ros_control
Централизованное управление парком MikroTik RouterOS: статус и мониторинг устройств, бэкапы в S3, обновление и откат ROS,
обновление прошивки, группы устройств и журнал событий. Backend на FastAPI и SQLite, WEB UI (Jinja2 + HTMX) и JSON API используют
один сервисный слой. Дизайн и требования — `ros_control.svg`, утверждённый макет интерфейса — [изменение 006](docs/changes/006-ui-redesign/plan.md).
```
Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устройстве)
⇅ ⇅
Metadata DB S3 (Yandex Object Storage)
```
## Быстрый старт
```bash
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_* (см. «Конфигурация»)
docker compose up -d --build # БД — в томе ros_data, схема обновляется при старте
```
- UI: `http://<хост>:${APP_PORT:-8000}/`, OpenAPI: `http://<хост>:${APP_PORT:-8000}/docs`.
- Вход в UI: `ADMIN_USER` / `ADMIN_PASSWORD` из `.env`; API — заголовок `Authorization: Bearer <API_TOKEN>`.
- Генерация секретов: `python -c "import secrets; print(secrets.token_urlsafe(32))"`;
ключ Fernet: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.
## Конфигурация (`.env`)
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `APP_PORT` | `8000` | Порт хоста, публикуемый `docker-compose.yml` (читает только compose, не приложение) |
| `APP_BIND` | `0.0.0.0` | Адрес публикации порта; `127.0.0.1` — только за reverse-proxy на том же хосте |
| `SECRET_KEY` | — | Ключ Fernet для паролей устройств. Обязателен; потеря ключа — потеря доступа к сохранённым паролям |
| `SESSION_SECRET` | — | Подпись cookie-сессии UI. Обязателен, ≥ 32 символов |
| `SESSION_COOKIE_SECURE` | `false` | Флаг Secure у cookie сессии; включить, когда приложение работает за TLS |
| `ADMIN_USER`, `ADMIN_PASSWORD` | `admin`, — | Вход в UI. Пароль обязателен, ≥ 12 символов |
| `API_TOKEN` | — | Bearer-токен API. Обязателен, ≥ 32 символов |
| `TRUSTED_PROXIES` | пусто | Доверенные прокси (CIDR через запятую). Только от них принимается `X-Forwarded-For` для IP клиента; пусто — заголовок всегда игнорируется |
| `DATABASE_URL` | `sqlite:///./data/ros_control.db` | База метаданных (в compose — том `ros_data`) |
| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_PREFIX` | Yandex Object Storage, `backups` | Бакет резервных копий |
| `BACKUPS_CACHE_TTL` | `60` | Время жизни кэша списка бакета, с; `0` — без кэша |
| `MAX_CONCURRENCY` | `10` | Одновременные обращения к устройствам |
| `ROS_CONNECT_TIMEOUT` | `4` | Установление соединения, с — скорость обнаружения недоступности |
| `ROS_TIMEOUT` | `30` | Ответ устройства, с — долгие операции |
| `POLL_INTERVAL` | `30` | Период фонового опроса, с; `0` — выключить |
| `UPDATE_CHECK_INTERVAL` | `1800` | Как часто опрос проверяет обновления ROS, с |
| `EVENTS_RETENTION_DAYS`, `EVENTS_MAX_ROWS` | `90`, `100000` | Ротация журнала по умолчанию; действующие значения меняются в UI; `0` — без ограничения |
Пустые, равные `change-me` или слишком короткие секреты и невалидный `SECRET_KEY` — отказ старта (`RuntimeError` без значений секретов).
## Архитектура
| Слой | Технологии |
|---|---|
| API | FastAPI, pydantic v2, Bearer-токен; долгие операции — фоновые задачи с ограничением параллелизма |
| UI | Jinja2 + HTMX, ванильный JS без сборки; сессия в cookie; шрифты IBM Plex локально (OFL), внешних ресурсов нет |
| БД | SQLite (WAL), SQLAlchemy 2; версия схемы — `PRAGMA user_version`, миграции в `app/migrations.py` |
| Устройства и S3 | RouterOS REST API через httpx; S3 — boto3 |
```
app/ main.py config.py db.py migrations.py models.py ids.py security.py process_lock.py s3.py
app/ros/ client.py (REST-клиент) operations.py (статус, бэкап, обновления, откат)
app/services/ devices groups backups ops jobs poller events settings rotation
app/api/ v1.py (JSON API)
app/ui/ routes.py templates/ static/ (стили, app.js, шрифты)
tests/ автотесты (pytest) — по модулям: test_security test_devices test_operations test_backups
test_events test_ids_migrations test_architecture; helpers.py — общие хелперы, conftest.py — фикстуры
docs/changes/ планы и итоги доработок docs/reviews/ отчёты ревью
```
## Функциональность и правила
**Устройства**
- Добавление, изменение, удаление; пароль хранится зашифрованным (Fernet) и в ответы API не попадает.
- Имя задаётся только при создании и не меняется: оно входит в ключи бэкапов в S3. Клик по имени открывает окно изменения.
- Примечание до 500 символов — подсказкой при наведении на имя.
- Статус: online/offline (с причиной недоступности), модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа.
- Мониторинг: опрос каждые `POLL_INTERVAL` секунд, недоступность определяется за ~`ROS_CONNECT_TIMEOUT` секунд; страница обновляет статусы сама.
**Колонка «Upgrade ROS»**
| Состояние | Отображение |
|---|---|
| На канале есть более новая версия | «↑ X» |
| Установлена версия канала | «актуально» |
| Версия канала старше установленной (например, после перехода на `long-term`) | «канал: X» — не обновление; откат — отдельной операцией |
| Последняя проверка обновлений завершилась ошибкой | «проверка не удалась», текст ошибки в подсказке |
| Проверка ещё не выполнялась (или у устройства нет интернета) | «—» |
**Колонка «Upgrade FW»** (изменение 027)
| Состояние | Отображение |
|---|---|
| В установленную ROS встроена более новая прошивка платы | «↑ X» |
| Записана версия, встроенная в ROS | «актуально» |
| Прошивка платы новее встроенной в ROS (например, плата не откатилась вместе с ROS — 023) | «в ROS: X» — не обновление; откат — отдельной операцией |
| Нет RouterBOARD firmware (CHR) или устройство не опрошено | «—» |
Прошивка сравнивается с версией, встроенной в установленную ROS (`upgrade-firmware`), а не с серверами MikroTik: `/system/routerboard/upgrade` только записывает то, что уже скачано вместе с ROS.
**Группы и фильтры**
- Устройство — в одной группе или «Без группы». Вкладки групп со счётчиками, страница «Группы»; группу можно создать при добавлении устройства.
- Фильтры устройств: поиск (имя, адрес, модель), статус, наличие обновлений, канал. Фильтры бэкапов: группа, устройство, тип файла, период, имя файла. Фильтры сохраняются в URL.
**Операции и задачи**
- Действия по устройству — меню «⋯» строки; над выбранными — полоса инструментов; через API — и над целой группой.
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS, откат FW (027) выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
- Обновление ROS: устройство перезагружается после скачивания. Обновление FW: перезагрузка, как только в журнале устройства появилась запись «Firmware upgraded successfully…».
- Штатное обновление не выполняет откат: версия канала (или встроенной прошивки) старше установленной/записанной — «Обновление не требуется», с подсказкой на соответствующий откат.
**Откат ROS до версии канала / откат FW до версии ROS**
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Одно окно на оба вида отката (параметр `kind`): для ROS — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X») и пункт в меню «Обновление»; для FW (изменение 027) — «Откатить прошивку…» (виден в состоянии «в ROS: X») и «Откатить прошивку до версии ROS…» там же. Устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
- Задача: проверка на устройстве (целевая версия совпадает с введённой и старше записанной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и запись. Для FW прошивка берётся из установленной ROS, а не с серверов MikroTik.
**Бэкапы**
- Пара файлов: `.backup` (без шифрования) и `.rsc` (с `show-sensitive` — пароли и ключи). Сервер создаёт их через REST, скачивает, загружает в S3 и удаляет с устройства.
- Список бакета, скачивание по временной ссылке, удаление одного файла или выбранных (до 500 за раз).
**Идентификаторы**
- `<префикс>_<uuid7>`: `dev_` устройство, `grp_` группа, `job_` задача, `bkp_` резервная копия, `evt_` запись журнала. Типы не пересекаются, ID сортируются по времени и не переиспользуются. ID чужого типа или неверного формата — 404.
- Резервная копия — пара файлов с одним `bkp_` ID; у файлов в S3 метаданные `backup-id`/`device-id` (Yandex отдаёт `Backup-Id`/`Device-Id`). Копия без файлов в бакете помечается удалённой и остаётся в истории.
**Журнал событий**
- Создание, изменение и удаление устройств и групп, перенос устройств, задачи и бэкапы, online/offline (только смены), вход в UI, ротация и очистка журнала. Запись: `evt_` ID, ID сущности, актор (`ui:<пользователь>`, `api`, `poller`, `system`, `anonymous`), данные.
- Страница «Журнал»: фильтры по типу, актору, устройству, периоду и тексту; «Показать ещё 100»; окно записи с полными ID и данными.
- Ротация по сроку и числу записей (настройки в UI, хранятся в БД): при старте, раз в час и после сохранения; оставляет запись `journal.rotated`.
- Очистка — только в UI, через окно с паролем пользователя; остаётся запись `journal.cleared`. Через API — только чтение журнала и настроек.
## Требования к устройствам
- RouterBOARD и виртуальные **CHR** (у CHR нет `/system/routerboard`: FW не показывается, обновление FW пропускается).
- RouterOS **7.1+**, сервис `www-ssl` (REST, порт 443; **не** `api-ssl` 8729 и не `api` 8728). Для доверенной сети допустим `www` (порт 80) со снятым флагом «HTTPS» — логин и пароль идут открытым текстом.
- Пользователь с политиками `read`, `write`, `policy`, `test`, `ftp`, `reboot`, `sensitive` (проще — группа `full`). Группа `write` не подходит: бэкап падает с `not enough permissions`. После смены прав пересоздайте подключение к устройству.
- Доступ устройств к S3 не нужен: файлы загружает Control Server (нужен доступ к `storage.yandexcloud.net:443`).
## API (`/api/v1`)
| Область | Эндпоинты |
|---|---|
| Устройства | `GET\|POST /devices` (фильтры `group`, `q`, `status`, `updates`, `channel`), `GET\|PATCH\|DELETE /devices/{id}` (смена имени — 400), `POST /devices/refresh`, `POST /devices/{id}/refresh` |
| Операции | `POST /devices/{id}/backups`, `PUT /devices/{id}/update/channel`, `POST /devices/{id}/update/install`, `POST /devices/{id}/firmware/upgrade`, `POST /devices/{id}/update/downgrade`, `POST /devices/{id}/firmware/downgrade` (027) — оба отката `{"target_version"}` обязателен |
| Групповые | `POST /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade`, `POST /batch/fw_downgrade` (027) — оба `target_version` — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
| Группы | `GET\|POST /groups`, `PATCH\|DELETE /groups/{id}` (устройства остаются без группы) |
| Бэкапы | `GET /backups` (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш), `GET /backups/download?key=`, `DELETE /backups?key=`, `POST /backups/delete` (`{"keys": [...]}` → `{"deleted", "failed"}`) |
| Задачи | `GET /jobs`, `GET /jobs/{id}` |
| Журнал | `GET /events` (фильтры `entity_id`, `type`, `device_id`, `job_id`, `actor`, `date_from`, `date_to`, `q`; `before=<evt_…>`, `limit`), `GET /events/{id}`, `GET\|PUT /events/settings` |
**Соглашения**
- Все ID — строки с префиксом типа; `group_id: null` — без группы; пароль устройства передаётся только при записи.
- Операции над устройствами (бэкап, обновления, откат, групповые) отвечают `202 {"job_ids": [...]}`; результат — в задаче. Смена канала одного устройства и `refresh` — синхронные.
- Ошибки: 400 — неверные данные, 401 — нет или неверный токен, 404 — нет объекта или ID чужого типа, 422 — нарушение схемы, 502 — ошибка S3.
```bash
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devices | python3 -m json.tool
```
Справочники: [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/).
## Безопасность
**Секреты**
- Требования — в «Конфигурации»; с небезопасными значениями приложение не стартует.
- Пароли устройств шифруются `SECRET_KEY`; секреты не пишутся ни в журнал, ни в сообщения об ошибках.
**Вход в UI**
- 5 неверных попыток за 10 минут с одного IP → IP заблокирован на 10 минут (429, пароль не проверяется); неверный пароль — 401.
- Блокировка по IP, а не по имени: единственного администратора нельзя заблокировать чужими попытками. За reverse-proxy без `TRUSTED_PROXIES` все клиенты видны с IP прокси — одна блокировка на всех.
- `TRUSTED_PROXIES` (CIDR через запятую) включает разбор `X-Forwarded-For`: только когда адрес соединения (peer) сам входит в доверенную сеть, заголовок берётся в расчёт — цепочка разбирается справа налево, первый адрес не из доверенной сети становится IP клиента (блокировка входа, `data.ip` в `auth.*`). Пустой список (по умолчанию) или недоверенный peer — заголовок полностью игнорируется, подделать IP нельзя. Доверяя сети Docker-моста, вы делаете доверенными и процессы хоста (не только сам reverse-proxy) — используйте узкий CIDR.
- Очистка журнала требует пароль; 5 неверных за 10 минут блокируют её на 10 минут.
- Редиректы после форм — только на локальный путь.
**Данные**
- `.rsc` содержит пароли и ключи, `.backup` не зашифрован: ограничьте доступ к бакету и ссылкам скачивания.
- Порт приложения не открывайте в недоверенную сеть; за TLS включите `SESSION_COOKIE_SECURE=true`.
## Эксплуатация
- Контейнер работает от непривилегированного пользователя (uid 10001), перезапуск `unless-stopped`.
- БД (SQLite) — в томе `ros_data`, переживает пересборку; `docker compose down -v` удаляет её. Схема обновляется при старте; перед миграцией ID создаётся копия `ros_control.db.bak-<метка>`.
- **Один процесс на БД**: файловая блокировка `<файл БД>.lock`; второй процесс (`--workers 2+`, вторая копия контейнера на том же томе) не стартует. В памяти процесса — семафор задач, счётчики неудачных паролей, кэш бакета.
- **Кэш бакета**: страница «Бэкапы» и `GET /api/v1/backups` перечитывают бакет не чаще `BACKUPS_CACHE_TTL`; собственные изменения сбрасывают кэш, внешние видны по «Обновить список».
- **Копия БД**: SQLite работает в режиме WAL — файл БД без `-wal` может быть неполным. Копировать через `sqlite3 <БД> ".backup <копия>"` или вместе с `-wal`/`-shm`.
- Текущий стенд опубликован на порту **8001** — задан `APP_PORT=8001` в `.env` стенда (порт 8000 на хосте занят другим процессом); override-файл не нужен, `docker compose up -d` берёт порт из `.env`.
## Интерфейс
- Экраны: «Устройства» (вкладки групп, фильтры, таблица, карточка «Задачи»), «Бэкапы», «Группы», «Журнал».
- Пока строки не выбраны, полоса инструментов показывает фильтры; при выборе — действия над выбранными.
- Добавление и изменение устройств, группы, откат ROS, запись журнала — в окнах поверх страницы; запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript.
- Меню и выпадающие списки в едином стиле «⋯», не обрезаются таблицей и раскрываются вверх у нижнего края. Списки строятся поверх скрытого `<select>`: без JavaScript работает стандартный.
- Светлая и тёмная темы — по настройке системы.
## Тесты
39 тестов по модулям предметных областей (`tests/test_*.py`), фоновый опрос выключен; стенд не нужен (временная SQLite,
RouterOS и S3 — заглушки). Тест-линтер (`test_architecture.py`) не допускает синхронных обращений к БД в `async`-коде.
```bash
python3 -m venv venv && venv/bin/pip install -r requirements.txt
venv/bin/python -m pytest -q # все тесты
venv/bin/python -m pytest -q tests/test_backups.py # один модуль
```
Локальный запуск без Docker: `set -a; . ./.env; set +a; venv/bin/uvicorn app.main:app --reload`.
## История изменений
Каждая доработка описана в `docs/changes/<номер>/`: `plan.md` — план, `summary.md` — итог.
| № | Изменение | Документы |
|---|---|---|
| 001 | Первая версия | [план](docs/changes/001-initial-implementation/plan.md) · [итог](docs/changes/001-initial-implementation/summary.md) |
| 002 | HTTP-подключение, исправления по тесту на устройстве | [план](docs/changes/002-http-support-and-fixes/plan.md) · [итог](docs/changes/002-http-support-and-fixes/summary.md) |
| 003 | Бэкап: скачивание через REST, загрузка в S3 сервером | [план](docs/changes/003-download-via-rest-upload-from-server/plan.md) · [итог](docs/changes/003-download-via-rest-upload-from-server/summary.md) |
| 004 | Колонки Upgrade ROS/FW, меню действий «⋯» | [план](docs/changes/004-upgrade-columns-and-actions-menu/plan.md) · [итог](docs/changes/004-upgrade-columns-and-actions-menu/summary.md) |
| 005 | Группы устройств, фильтры устройств и бэкапов | [план](docs/changes/005-groups-and-filters/plan.md) · [итог](docs/changes/005-groups-and-filters/summary.md) |
| 006 | Новый интерфейс по утверждённому макету | [план](docs/changes/006-ui-redesign/plan.md) · [итог](docs/changes/006-ui-redesign/summary.md) |
| 007 | Фоновый опрос, быстрое обнаружение недоступности | [план](docs/changes/007-fast-offline-detection/plan.md) · [итог](docs/changes/007-fast-offline-detection/summary.md) |
| 008 | Примечание к устройству | [план](docs/changes/008-device-note/plan.md) · [итог](docs/changes/008-device-note/summary.md) |
| 009 | Выравнивание полей в окне устройства | [план](docs/changes/009-dialog-label-alignment/plan.md) · [итог](docs/changes/009-dialog-label-alignment/summary.md) |
| 010 | `.rsc` с `show-sensitive` | [план](docs/changes/010-export-show-sensitive/plan.md) · [итог](docs/changes/010-export-show-sensitive/summary.md) |
| 011 | Выпадающие меню не обрезаются | [план](docs/changes/011-dropdown-clipping/plan.md) · [итог](docs/changes/011-dropdown-clipping/summary.md) |
| 012 | Клик по имени устройства открывает окно изменения | [план](docs/changes/012-clickable-device-name/plan.md) · [итог](docs/changes/012-clickable-device-name/summary.md) |
| 013 | Групповое удаление бэкапов | [план](docs/changes/013-bulk-delete-backups/plan.md) · [итог](docs/changes/013-bulk-delete-backups/summary.md) |
| 014 | Поддержка CHR, строгое сравнение версий ROS | [план](docs/changes/014-chr-support/plan.md) · [итог](docs/changes/014-chr-support/summary.md) |
| 015 | Имя устройства задаётся только при создании | [план](docs/changes/015-immutable-device-name/plan.md) · [итог](docs/changes/015-immutable-device-name/summary.md) |
| 016 | Уникальные ID сущностей, журнал событий | [план](docs/changes/016-unique-ids-and-event-log/plan.md) · [итог](docs/changes/016-unique-ids-and-event-log/summary.md) |
| 017 | Журнал в UI: ротация, очистка с паролем | [план](docs/changes/017-events-ui-rotation/plan.md) · [итог](docs/changes/017-events-ui-rotation/summary.md) |
| 018 | Корректность: удаление бэкапа, миграции, групповая смена канала задачами | [план](docs/changes/018-correctness-consistency/plan.md) · [итог](docs/changes/018-correctness-consistency/summary.md) |
| 019 | Производительность: кэш бакета, БД вне event loop, один процесс на БД | [план](docs/changes/019-performance-scaling/plan.md) · [итог](docs/changes/019-performance-scaling/summary.md) |
| 020 | Выпадающие списки в стиле меню «⋯» | [план](docs/changes/020-custom-select-menus/plan.md) · [итог](docs/changes/020-custom-select-menus/summary.md) |
| 021 | Безопасность: секреты, блокировка входа, Secure-cookie, редиректы | [план](docs/changes/021-security-hardening/plan.md) · [итог](docs/changes/021-security-hardening/summary.md) |
| 022 | Остаток синхронной БД в async-коде, тест-линтер | [план](docs/changes/022-async-db-remainder/plan.md) · [итог](docs/changes/022-async-db-remainder/summary.md) |
| 023 | Состояния «Upgrade ROS», откат ROS до версии канала | [план](docs/changes/023-ros-downgrade/plan.md) · [итог](docs/changes/023-ros-downgrade/summary.md) |
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
| 025 | Доверенные прокси (реальный IP клиента), параметризация порта | [план](docs/changes/025-trusted-proxies-port/plan.md) · [итог](docs/changes/025-trusted-proxies-port/summary.md) |
| 026 | Тесты по модулям предметных областей | [план](docs/changes/026-split-tests/plan.md) · [итог](docs/changes/026-split-tests/summary.md) |
| 027 | Состояния «Upgrade FW», откат прошивки RouterBOARD до версии ROS | [план](docs/changes/027-fw-downgrade/plan.md) · [итог](docs/changes/027-fw-downgrade/summary.md) |
## Отчёты ревью
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
- [Повторное ревью 2026-09-28 12:43](docs/reviews/2026-09-28-1243-codebase-review.md) (→ 022, 024)
- [Третье ревью 2026-09-28 17:35](docs/reviews/2026-09-28-1735-codebase-review.md) (→ 025, 026)
- [Четвёртое ревью 2026-09-28 21:32](docs/reviews/2026-09-28-2132-codebase-review.md) (открыты п. 11, 13, 15, 19; п. 18 отложен)