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