Глобально уникальные 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>
192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
import re
|
|
from dataclasses import dataclass
|
|
|
|
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.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: str
|
|
name: str
|
|
host: str
|
|
port: int
|
|
username: str
|
|
password: str
|
|
verify_tls: bool
|
|
use_tls: bool
|
|
|
|
|
|
def validate_name(name: str) -> None:
|
|
if not NAME_RE.match(name):
|
|
raise ValueError("Имя: латиница, цифры, '.', '_', '-' (до 64 символов)")
|
|
|
|
|
|
def list_devices() -> list[Device]:
|
|
with session_scope() as s:
|
|
return list(s.query(Device).order_by(Device.name))
|
|
|
|
|
|
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:
|
|
raise LookupError(f"Устройство {device_id} не найдено")
|
|
return d
|
|
|
|
|
|
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} не найдена")
|
|
|
|
|
|
NOTE_MAX = 500
|
|
|
|
|
|
def _clean_note(note: str | None) -> str | None:
|
|
note = (note or "").strip()
|
|
if len(note) > NOTE_MAX:
|
|
raise ValueError(f"Примечание: не более {NOTE_MAX} символов")
|
|
return note or None
|
|
|
|
|
|
def _resolve_group(s, group_id: str | None, new_group: str | None) -> str | None:
|
|
"""Группа устройства. new_group создаёт группу (или берёт существующую с таким именем)
|
|
в той же транзакции, что и устройство: при ошибке не остаётся лишней группы."""
|
|
name = (new_group or "").strip()
|
|
if name:
|
|
if len(name) > 64:
|
|
raise ValueError("Название группы: от 1 до 64 символов")
|
|
g = s.query(Group).filter_by(name=name).first()
|
|
if g is 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: 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:
|
|
group_id = _resolve_group(s, group_id, new_group)
|
|
if s.query(Device).filter_by(name=name).first():
|
|
raise ValueError(f"Устройство с именем {name} уже существует")
|
|
d = Device(name=name, host=host, port=port, username=username,
|
|
password_enc=security.encrypt(password), verify_tls=verify_tls, use_tls=use_tls,
|
|
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: 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 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 — открепить от группы
|
|
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: # пустое примечание — очистить
|
|
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: 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: 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)
|
|
|
|
|
|
def open_client(conn: Conn) -> RosClient:
|
|
return RosClient(conn.host, conn.port, conn.username, conn.password,
|
|
conn.verify_tls, get_settings().ros_timeout, conn.use_tls,
|
|
connect_timeout=get_settings().ros_connect_timeout)
|
|
|
|
|
|
def has_ros_update(st: dict) -> bool:
|
|
return version_newer(st.get("ros_latest"), st.get("ros_installed"))
|
|
|
|
|
|
def has_fw_update(st: dict) -> bool:
|
|
return bool(st.get("fw_current") and st.get("fw_upgrade") and st["fw_upgrade"] != st["fw_current"])
|
|
|
|
|
|
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
|
|
updates: str = "", channel: str = "") -> list[Device]:
|
|
"""Общий фильтр списка устройств для UI и API (по кэшу статуса).
|
|
|
|
group: "" — все, "none" — без группы, иначе id группы. status: online/offline.
|
|
updates: ros / fw / any (есть обновление) / none (обновлений нет).
|
|
"""
|
|
q = q.strip().lower()
|
|
out = []
|
|
for d in devs:
|
|
st = d.status
|
|
if group == "none" and d.group_id is not None:
|
|
continue
|
|
if group not in ("", "none") and str(d.group_id) != group:
|
|
continue
|
|
if q and q not in " ".join((d.name, d.host, st.get("model") or "")).lower():
|
|
continue
|
|
if status == "online" and d.online is not True:
|
|
continue
|
|
if status == "offline" and d.online is True:
|
|
continue
|
|
ros_new, fw_new = has_ros_update(st), has_fw_update(st)
|
|
if (updates == "ros" and not ros_new) or (updates == "fw" and not fw_new):
|
|
continue
|
|
if (updates == "any" and not (ros_new or fw_new)) or (updates == "none" and (ros_new or fw_new)):
|
|
continue
|
|
if channel and st.get("channel") != channel:
|
|
continue
|
|
out.append(d)
|
|
return out
|