Уникальные 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
+96
-39
@@ -1,14 +1,15 @@
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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.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"])
|
||||
|
||||
@@ -21,7 +22,7 @@ class DeviceIn(BaseModel):
|
||||
password: str
|
||||
verify_tls: bool = False
|
||||
use_tls: bool = True # False = HTTP (сервис www, порт 80): пароль идёт открытым текстом
|
||||
group_id: int | None = None
|
||||
group_id: str | None = None # grp_…
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@@ -33,20 +34,20 @@ class DevicePatch(BaseModel):
|
||||
password: str | None = None
|
||||
verify_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 — очистить примечание
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
"""Пароль устройства в ответы не попадает."""
|
||||
id: int
|
||||
id: str # dev_…
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
verify_tls: bool
|
||||
use_tls: bool
|
||||
group_id: int | None
|
||||
group_id: str | None
|
||||
note: str | None
|
||||
online: bool | None
|
||||
status: dict
|
||||
@@ -60,8 +61,8 @@ class DeviceOut(BaseModel):
|
||||
|
||||
|
||||
class JobOut(BaseModel):
|
||||
id: int
|
||||
device_id: int | None
|
||||
id: str # job_…
|
||||
device_id: str | None
|
||||
device_name: str
|
||||
type: str
|
||||
status: str
|
||||
@@ -81,25 +82,25 @@ class GroupIn(BaseModel):
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
id: int
|
||||
id: str # grp_…
|
||||
name: str
|
||||
device_count: int = 0
|
||||
|
||||
|
||||
class BatchIn(BaseModel):
|
||||
"""Устройства, над которыми выполняется операция: список id и/или целая группа."""
|
||||
device_ids: list[int] = Field(default_factory=list)
|
||||
group_id: int | None = None
|
||||
device_ids: list[str] = Field(default_factory=list) # dev_…
|
||||
group_id: str | None = None # grp_…
|
||||
|
||||
def resolve(self) -> list[int]:
|
||||
ids = list(self.device_ids)
|
||||
def resolve(self) -> list[str]:
|
||||
found = list(self.device_ids)
|
||||
if self.group_id is not None:
|
||||
groups.get_group(self.group_id) # 404, если группы нет
|
||||
ids += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
|
||||
ids = list(dict.fromkeys(ids))
|
||||
if not ids:
|
||||
found += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
|
||||
found = list(dict.fromkeys(found))
|
||||
if not found:
|
||||
raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id")
|
||||
return ids
|
||||
return found
|
||||
|
||||
|
||||
class BatchChannelIn(BatchIn):
|
||||
@@ -110,7 +111,7 @@ class BatchChannelIn(BatchIn):
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceOut])
|
||||
async def list_devices(
|
||||
group: str = "", # "" — все, "none" — без группы, иначе id группы
|
||||
group: str = "", # "" — все, "none" — без группы, иначе ID группы (grp_…)
|
||||
q: str = "",
|
||||
status: Literal["", "online", "offline"] = "",
|
||||
updates: Literal["", "ros", "fw", "any", "none"] = "",
|
||||
@@ -134,13 +135,13 @@ async def create_group(body: GroupIn):
|
||||
|
||||
|
||||
@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)
|
||||
return next(g for g in groups.list_groups() if g["id"] == group_id)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@@ -150,22 +151,22 @@ async def create_device(body: DeviceIn):
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
await ops.refresh_status(device_id)
|
||||
return DeviceOut.of(devices.get_device(device_id))
|
||||
@@ -180,24 +181,24 @@ async def refresh_all():
|
||||
# --- операции (долгие — через jobs) ---
|
||||
|
||||
@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])}
|
||||
|
||||
|
||||
@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)
|
||||
await ops.set_channel(device_id, body.channel)
|
||||
return DeviceOut.of(devices.get_device(device_id))
|
||||
|
||||
|
||||
@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])}
|
||||
|
||||
|
||||
@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])}
|
||||
|
||||
|
||||
@@ -209,20 +210,20 @@ async def batch(action: Literal["backup", "ros_update", "fw_update"], body: Batc
|
||||
|
||||
@router.put("/batch/channel", response_model=list[DeviceOut])
|
||||
async def batch_channel(body: BatchChannelIn):
|
||||
ids = body.resolve()
|
||||
for i in ids:
|
||||
targets = body.resolve()
|
||||
for i in targets:
|
||||
devices.get_device(i)
|
||||
for i in ids:
|
||||
for i in targets:
|
||||
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 ---
|
||||
|
||||
@router.get("/backups")
|
||||
async def list_backups(
|
||||
device_id: int | None = None,
|
||||
group: str = "", # "" — все, "none" — без группы, иначе id группы
|
||||
device_id: str | None = None,
|
||||
group: str = "", # "" — все, "none" — без группы, иначе ID группы (grp_…)
|
||||
kind: Literal["", "backup", "rsc"] = "",
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
@@ -252,9 +253,65 @@ async def delete_backups(body: KeysIn):
|
||||
|
||||
@router.delete("/backups", status_code=204)
|
||||
async def delete_backup(key: str):
|
||||
if not s3.key_allowed(key):
|
||||
raise ValueError("Недопустимый ключ")
|
||||
await s3.delete_object(key)
|
||||
deleted, failed = await backups.delete_many([key])
|
||||
if failed:
|
||||
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)
|
||||
async def get_job(job_id: int):
|
||||
async def get_job(job_id: str):
|
||||
return jobs.get_job(job_id)
|
||||
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
||||
poll_interval: int = 30 # период фонового опроса устройств, с; 0 — отключить
|
||||
update_check_interval: int = 1800 # как часто в опросе проверять обновления ROS (тяжёлый запрос), с
|
||||
|
||||
# значения по умолчанию для ротации журнала; действующие настройки хранятся в БД и меняются из UI
|
||||
events_retention_days: int = 90 # 0 — без ограничения по сроку
|
||||
events_max_rows: int = 100000 # 0 — без ограничения по числу записей
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -25,9 +25,14 @@ def init_db(url: str | None = None) -> None:
|
||||
_SessionLocal = sessionmaker(_engine, expire_on_commit=False)
|
||||
from app import models # noqa: F401 (регистрация моделей)
|
||||
|
||||
Base.metadata.create_all(_engine)
|
||||
Base.metadata.create_all(_engine) # у новой БД — все таблицы, у старой — только недостающие (events)
|
||||
_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:
|
||||
"""create_all не добавляет колонки в существующие таблицы — докидываем вручную."""
|
||||
@@ -36,7 +41,7 @@ def _migrate() -> None:
|
||||
if "use_tls" not in cols:
|
||||
conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN use_tls BOOLEAN NOT NULL DEFAULT 1")
|
||||
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:
|
||||
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.config import get_settings
|
||||
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
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
jobs.fail_stale_jobs()
|
||||
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
|
||||
if task:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
for t in (task, reconcile, rotator):
|
||||
if t:
|
||||
t.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await t
|
||||
|
||||
|
||||
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 app.db import Base
|
||||
from app.ids import new_id
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
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):
|
||||
__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)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
id: Mapped[str] = _pk("dev")
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
host: Mapped[str] = mapped_column(String(255))
|
||||
port: Mapped[int] = mapped_column(Integer, default=443)
|
||||
@@ -29,7 +35,7 @@ class Device(Base):
|
||||
password_enc: Mapped[str] = mapped_column(Text)
|
||||
verify_tls: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
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) # свободное примечание к устройству
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
@@ -48,10 +54,15 @@ class Device(Base):
|
||||
|
||||
|
||||
class Backup(Base):
|
||||
"""Резервная копия = пара файлов (.backup + .rsc). Строка не удаляется: при удалении файлов из бакета
|
||||
ставится deleted_at, ID остаётся в истории."""
|
||||
__tablename__ = "backups"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
device_id: Mapped[int] = mapped_column(ForeignKey("devices.id", ondelete="CASCADE"))
|
||||
id: Mapped[str] = _pk("bkp")
|
||||
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)
|
||||
status: Mapped[str] = mapped_column(String(16), default="running") # running|done|failed
|
||||
key_binary: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -62,11 +73,36 @@ class Backup(Base):
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
device_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
id: Mapped[str] = _pk("job")
|
||||
device_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
device_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
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
|
||||
message: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
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)
|
||||
|
||||
|
||||
async def upload_file(path, key: str) -> None:
|
||||
"""PutObject: загрузка файла в бакет от имени Control Server."""
|
||||
await asyncio.to_thread(_client().upload_file, str(path), get_settings().s3_bucket, key)
|
||||
async def upload_file(path, key: str, metadata: dict[str, str] | None = None) -> None:
|
||||
"""PutObject: загрузка файла в бакет от имени Control Server. metadata — пользовательские метаданные объекта
|
||||
(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:
|
||||
|
||||
+45
-1
@@ -1,4 +1,6 @@
|
||||
import hmac
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import Depends, HTTPException
|
||||
@@ -31,7 +33,49 @@ def check_admin(user: str, password: str) -> bool:
|
||||
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
|
||||
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")
|
||||
from app.services import events # локальный импорт: security загружается раньше сервисов
|
||||
|
||||
events.set_actor("api")
|
||||
+82
-8
@@ -1,38 +1,108 @@
|
||||
"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
|
||||
"""Список бэкапов из бакета с фильтрами и связью с метаданными (backups.id = bkp_…)."""
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import date
|
||||
|
||||
from app import s3
|
||||
from app.services import devices, groups
|
||||
from app import ids, s3
|
||||
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:
|
||||
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,
|
||||
date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]:
|
||||
"""Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}).
|
||||
Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства.
|
||||
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе id группы.
|
||||
Даты — по времени изменения объекта (UTC), включительно."""
|
||||
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе ID группы (grp_…).
|
||||
Даты — по времени изменения объекта (UTC), включительно. Каждому файлу сопоставлен backup_id."""
|
||||
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()}
|
||||
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)
|
||||
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)}
|
||||
q = q.strip().lower()
|
||||
out = []
|
||||
for item in everything:
|
||||
parts = item["key"].split("/")
|
||||
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:
|
||||
continue
|
||||
if group == "none" and item["group"] is not None:
|
||||
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
|
||||
if kind and item["kind"] != kind:
|
||||
continue
|
||||
@@ -74,4 +144,8 @@ async def delete_many(keys: list[str]) -> tuple[int, int]:
|
||||
return False
|
||||
|
||||
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)
|
||||
+36
-13
@@ -1,19 +1,20 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app import security
|
||||
from app import ids, security
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.models import Device, Group
|
||||
from app.ros.operations import version_newer
|
||||
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 — только безопасные символы
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conn:
|
||||
id: int
|
||||
id: str
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
@@ -33,7 +34,8 @@ def list_devices() -> list[Device]:
|
||||
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:
|
||||
d = s.get(Device, device_id)
|
||||
if d is None:
|
||||
@@ -41,7 +43,9 @@ def get_device(device_id: int) -> Device:
|
||||
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:
|
||||
raise LookupError(f"Группа {group_id} не найдена")
|
||||
|
||||
@@ -56,7 +60,7 @@ def _clean_note(note: str | None) -> str | 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 создаёт группу (или берёт существующую с таким именем)
|
||||
в той же транзакции, что и устройство: при ошибке не остаётся лишней группы."""
|
||||
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)
|
||||
s.add(g)
|
||||
s.flush()
|
||||
events.record("group.created", "group", g.id, f"Группа «{name}» создана", data={"name": name}, s=s)
|
||||
return g.id
|
||||
_check_group(s, group_id)
|
||||
return group_id
|
||||
|
||||
|
||||
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)
|
||||
note = _clean_note(note)
|
||||
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)
|
||||
s.add(d)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
d = s.get(Device, device_id)
|
||||
if d is None:
|
||||
raise LookupError(f"Устройство {device_id} не найдено")
|
||||
if fields.get("name") and fields["name"] != d.name: # имя — часть ключей бэкапов в S3
|
||||
raise ValueError("Имя устройства нельзя изменить")
|
||||
changed = []
|
||||
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])
|
||||
changed.append(k)
|
||||
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: # пустое примечание — очистить
|
||||
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"): # пустой пароль = не менять
|
||||
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
|
||||
|
||||
|
||||
def delete_device(device_id: int) -> None:
|
||||
def delete_device(device_id: str) -> None:
|
||||
ids.check(device_id, "dev")
|
||||
with session_scope() as s:
|
||||
d = s.get(Device, device_id)
|
||||
if d is None:
|
||||
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)
|
||||
|
||||
|
||||
def get_conn(device_id: int) -> Conn:
|
||||
def get_conn(device_id: str) -> Conn:
|
||||
d = get_device(device_id)
|
||||
return Conn(d.id, d.name, d.host, d.port, d.username,
|
||||
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 app import ids
|
||||
from app.db import session_scope
|
||||
from app.models import Device, Group
|
||||
from app.services import events
|
||||
|
||||
|
||||
def _clean(name: str) -> str:
|
||||
@@ -19,7 +21,8 @@ def list_groups() -> list[dict]:
|
||||
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:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None:
|
||||
@@ -35,10 +38,12 @@ def create_group(name: str) -> Group:
|
||||
g = Group(name=name)
|
||||
s.add(g)
|
||||
s.flush()
|
||||
events.record("group.created", "group", g.id, f"Группа «{name}» создана", data={"name": name}, s=s)
|
||||
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)
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
@@ -46,23 +51,35 @@ def rename_group(group_id: int, name: str) -> Group:
|
||||
raise LookupError(f"Группа {group_id} не найдена")
|
||||
if s.query(Group).filter(Group.name == name, Group.id != group_id).first():
|
||||
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
|
||||
|
||||
|
||||
def delete_group(group_id: int) -> None:
|
||||
def delete_group(group_id: str) -> None:
|
||||
"""Удаляет группу; устройства остаются и становятся «Без группы» (FK в SQLite не включены)."""
|
||||
ids.check(group_id, "grp")
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None:
|
||||
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))
|
||||
events.record("group.deleted", "group", g.id, f"Группа «{g.name}» удалена", data={"name": g.name, "detached_devices": detached}, s=s)
|
||||
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 — «Без группы»)."""
|
||||
for i in device_ids:
|
||||
ids.check(i, "dev")
|
||||
with session_scope() as s:
|
||||
if group_id is not None and s.get(Group, group_id) is None:
|
||||
raise LookupError(f"Группа {group_id} не найдена")
|
||||
if group_id is not None:
|
||||
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))
|
||||
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."""
|
||||
import asyncio
|
||||
|
||||
from app import ids
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.models import Device, Job, now
|
||||
from app.services import ops
|
||||
from app.services import events, ops
|
||||
|
||||
JOB_TYPES = {
|
||||
"backup": ops.run_backup,
|
||||
@@ -23,15 +24,19 @@ def _semaphore() -> asyncio.Semaphore:
|
||||
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:
|
||||
j = s.get(Job, job_id)
|
||||
j.status, j.message = status, message
|
||||
if status in ("done", "failed"):
|
||||
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():
|
||||
_finish(job_id, "running", "")
|
||||
try:
|
||||
@@ -40,11 +45,13 @@ async def _run(job_id: int, job_type: str, device_id: int) -> None:
|
||||
_finish(job_id, "failed", str(e))
|
||||
|
||||
|
||||
def start_jobs(job_type: str, device_ids: list[int]) -> list[int]:
|
||||
"""Создаёт задачи для списка устройств и запускает их в фоне. Возвращает id задач."""
|
||||
def start_jobs(job_type: str, device_ids: list[str]) -> list[str]:
|
||||
"""Создаёт задачи для списка устройств и запускает их в фоне. Возвращает ID задач."""
|
||||
if job_type not in JOB_TYPES:
|
||||
raise ValueError(f"Неизвестный тип задачи: {job_type}")
|
||||
job_ids = []
|
||||
for did in device_ids:
|
||||
ids.check(did, "dev")
|
||||
started = []
|
||||
with session_scope() as s:
|
||||
for did in device_ids:
|
||||
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)
|
||||
s.add(j)
|
||||
s.flush()
|
||||
job_ids.append((j.id, did))
|
||||
for jid, did in job_ids:
|
||||
task = asyncio.create_task(_run(jid, job_type, did))
|
||||
events.record("job.created", "job", j.id, f"Задача {job_type} для {d.name} создана", device_id=did,
|
||||
job_id=j.id, data={"type": job_type}, s=s)
|
||||
started.append((j.id, did))
|
||||
for jid, did in started:
|
||||
task = asyncio.create_task(_run(jid, job_type, did)) # наследует контекст (актор)
|
||||
_tasks.add(task)
|
||||
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]:
|
||||
@@ -66,7 +75,8 @@ def list_jobs(limit: int = 50) -> list[Job]:
|
||||
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:
|
||||
j = s.get(Job, job_id)
|
||||
if j is None:
|
||||
@@ -79,3 +89,5 @@ def fail_stale_jobs() -> None:
|
||||
with session_scope() as s:
|
||||
for j in s.query(Job).filter(Job.status.in_(("pending", "running"))):
|
||||
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.ros import operations as ros
|
||||
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:
|
||||
d = s.get(Device, device_id)
|
||||
if d is not None: # устройство могли удалить во время опроса
|
||||
was = d.online
|
||||
for k, v in fields.items():
|
||||
setattr(d, k, v)
|
||||
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)
|
||||
try:
|
||||
@@ -35,7 +41,7 @@ async def refresh_status(device_id: int) -> None:
|
||||
_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;
|
||||
полный — если нужна проверка обновлений (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))
|
||||
|
||||
|
||||
async def refresh_many(device_ids: list[int] | None = None) -> None:
|
||||
ids = device_ids if device_ids is not None else [d.id for d in devices.list_devices()]
|
||||
async def refresh_many(device_ids: list[str] | None = None) -> None:
|
||||
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)
|
||||
|
||||
async def one(i: int):
|
||||
async def one(i: str):
|
||||
async with sem:
|
||||
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 во временную папку →
|
||||
загрузить в S3 от имени сервера → удалить файлы с устройства."""
|
||||
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"]
|
||||
|
||||
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.get(Device, device_id).last_backup_requested_at = b.requested_at = now()
|
||||
s.flush()
|
||||
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", "прервано"
|
||||
try:
|
||||
@@ -90,7 +100,7 @@ async def run_backup(device_id: int) -> str:
|
||||
for name, key in files.items():
|
||||
local = Path(tmp) / name
|
||||
await ros.download_file(c, name, local)
|
||||
await s3.upload_file(local, key)
|
||||
await s3.upload_file(local, key, meta)
|
||||
finally:
|
||||
for name in files: # на устройстве файлы не остаются ни при успехе, ни при сбое
|
||||
try:
|
||||
@@ -105,20 +115,23 @@ async def run_backup(device_id: int) -> str:
|
||||
with session_scope() as s:
|
||||
b = s.get(Backup, backup_id)
|
||||
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}"
|
||||
|
||||
|
||||
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:
|
||||
await ros.set_channel(c, channel)
|
||||
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:
|
||||
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:
|
||||
return await ros.upgrade_firmware(c)
|
||||
@@ -4,12 +4,12 @@ import logging
|
||||
import time
|
||||
|
||||
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")
|
||||
|
||||
|
||||
async def cycle(last_full: dict[int, float]) -> None:
|
||||
async def cycle(last_full: dict[str, float]) -> None:
|
||||
"""Один проход по всем устройствам (параллельно, не более MAX_CONCURRENCY одновременно)."""
|
||||
s = get_settings()
|
||||
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)
|
||||
started = time.monotonic()
|
||||
|
||||
async def one(device_id: int) -> None:
|
||||
async def one(device_id: str) -> None:
|
||||
async with sem:
|
||||
full = started - last_full.get(device_id, float("-inf")) >= s.update_check_interval
|
||||
try:
|
||||
@@ -35,7 +35,8 @@ async def cycle(last_full: dict[int, float]) -> None:
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
last_full: dict[int, float] = {}
|
||||
events.set_actor("poller")
|
||||
last_full: dict[str, float] = {}
|
||||
interval = get_settings().poll_interval
|
||||
while True:
|
||||
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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
}
|
||||
+181
-22
@@ -1,4 +1,5 @@
|
||||
"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API."""
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
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.templating import Jinja2Templates
|
||||
|
||||
from app import s3, security
|
||||
from app import ids, s3, security
|
||||
from app.config import get_settings
|
||||
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
|
||||
|
||||
|
||||
@@ -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["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["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["ros_newer"] = version_newer
|
||||
|
||||
@@ -36,9 +51,11 @@ class LoginRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def require_login(request: Request) -> None:
|
||||
if not request.session.get("user"):
|
||||
async def require_login(request: Request) -> None:
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise LoginRequired()
|
||||
events.set_actor(f"ui:{user}") # async: ContextVar остаётся в контексте обработчика запроса
|
||||
|
||||
|
||||
router = APIRouter(include_in_schema=False)
|
||||
@@ -59,8 +76,10 @@ def _done(request: Request, fallback: str = "/"):
|
||||
return RedirectResponse(fallback, status_code=303)
|
||||
|
||||
|
||||
def _opt_int(value: str) -> int | None:
|
||||
return int(value) if str(value).strip().isdigit() else None
|
||||
def _opt_id(value: str, prefix: str) -> str | 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_
|
||||
@@ -73,6 +92,8 @@ def _flt(params) -> dict:
|
||||
for k, allowed in FILTER_VALUES.items():
|
||||
if flt[k] not in allowed:
|
||||
flt[k] = ""
|
||||
if flt["group"] not in ("", "none") and not ids.is_id(flt["group"], "grp"):
|
||||
flt["group"] = ""
|
||||
return flt
|
||||
|
||||
|
||||
@@ -117,8 +138,12 @@ async def login_form(request: Request):
|
||||
@router.post("/login")
|
||||
async def login(request: Request, username: str = Form(), password: str = Form()):
|
||||
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="Неверный логин или пароль")
|
||||
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)
|
||||
|
||||
|
||||
@@ -153,7 +178,7 @@ async def jobs_table(request: Request):
|
||||
|
||||
|
||||
@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())
|
||||
# без выбора обновляем все устройства текущего представления (группа + фильтры)
|
||||
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)])
|
||||
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="")):
|
||||
if not device_ids:
|
||||
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,
|
||||
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":
|
||||
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)])
|
||||
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 + редирект: счётчики вкладок обновляются)."""
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)])
|
||||
async def dialog_device_new(request: Request, group: str = ""):
|
||||
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())
|
||||
|
||||
|
||||
@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)
|
||||
return _render(request, "_device_form.html", in_modal=True, device=d, v=_device_values(d), error=None,
|
||||
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)])
|
||||
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)
|
||||
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)])
|
||||
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))
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# --- управление списком устройств ---
|
||||
|
||||
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, «без группы» или «Новая группа…» (тогда нужно название)."""
|
||||
if group_id == "__new__":
|
||||
if not new_group.strip():
|
||||
raise ValueError("Введите название новой группы")
|
||||
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)])
|
||||
async def device_new(request: Request, group: str = ""):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)])
|
||||
async def device_edit(request: Request, device_id: int):
|
||||
async def device_edit(request: Request, device_id: str):
|
||||
d = devices.get_device(device_id)
|
||||
return _device_form(request, d, _device_values(d))
|
||||
|
||||
|
||||
@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),
|
||||
use_tls: bool = Form(False), group_id: str = Form(""), new_group: 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)])
|
||||
async def device_delete(request: Request, device_id: int):
|
||||
async def device_delete(request: Request, device_id: str):
|
||||
devices.delete_device(device_id)
|
||||
return _done(request)
|
||||
|
||||
@@ -390,3 +415,137 @@ async def backup_delete(key: str = Form()):
|
||||
raise ValueError("Недопустимый ключ")
|
||||
await s3.delete_object(key)
|
||||
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();
|
||||
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);
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -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:last-child { border-top-right-radius: 11px; }
|
||||
.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">Группа
|
||||
<select class="field" name="group_id" data-newgroup style="width:100%">
|
||||
<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>
|
||||
</select>
|
||||
</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 %}
|
||||
<div class="card">
|
||||
<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>
|
||||
<thead><tr><th>#</th><th>Устройство</th><th>Операция</th><th>Статус</th><th>Сообщение</th><th>Создана (UTC)</th></tr></thead>
|
||||
<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>ID</th><th>Устройство</th><th>Операция</th><th>Статус</th><th>Сообщение</th><th>Создана (UTC)</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<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>{{ {"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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<select class="field {{ 'on' if flt.group }}" name="group" onchange="this.form.submit()" aria-label="Группа" style="width:170px">
|
||||
<option value="">Группа: любая</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 class="field {{ 'on' if flt.device }}" name="device" onchange="this.form.submit()" aria-label="Устройство" style="width:190px">
|
||||
<option value="">Устройство: любое</option>
|
||||
@@ -57,7 +57,7 @@
|
||||
<td style="font-weight:600">{{ i.device or "—" }}</td>
|
||||
<td class="muted">{{ i.group or "—" }}</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.last_modified|dt }}</td>
|
||||
<td class="c-menu">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</head>
|
||||
<body>
|
||||
{% 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">
|
||||
<a class="brand" href="/"><span class="brand-logo">{{ ui.icon("router") }}</span>ros_control</a>
|
||||
<nav class="nav" aria-label="Разделы">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user