storage: multi-storage management with per-storage quota
Replace the single fixed /records path with a list of storages and an
active selection (where new recordings go). Quota is now per-storage.
- config: StorageItem(id,name,type,path,quota_gb,nas-meta) + Storage keeps
global retention_days/segment_seconds; active + items. Migration from the
old single {path,quota_gb} schema → one local item.
- recorder: write to the active storage; restart_all() repoints on switch;
NAS-not-mounted guard (never write into the container overlay).
- indexer: scan ALL storage roots so recordings survive an active switch.
- retention: enforce quota PER storage via db.total_size_under /
oldest_recordings_under (path-prefix LIKE).
- api/storages: GET/POST/PUT/DELETE + activate + policy. NAS uses host-mount
(backend stores SMB params, generates the mount/fstab command, reports
ismount status). Retire old POST /api/storage.
- frontend: new "Хранилища" settings tab — table with inline per-row quota,
add form (local folder / NAS SMB), global policy (segment + retention).
- compose: add shared ./storages:/storages root for extra local folders and
host-mounted NAS shares.
VERSION 0.0.127.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
|||||||
|
"""REST: управление хранилищами записи — список, CRUD, выбор активного, политика.
|
||||||
|
|
||||||
|
Несколько хранилищ (local-папка или NAS/SMB) + одно активное (куда пишем новые записи).
|
||||||
|
Квота — на каждом хранилище. NAS монтируется на ХОСТЕ (backend не монтирует сам), здесь
|
||||||
|
лишь хранятся параметры и генерируется готовая команда mount/fstab."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from ..auth import require_role
|
||||||
|
from ..config import StorageItem, save_config
|
||||||
|
from ..runtime import runtime
|
||||||
|
from ..services.system import is_mounted, storage_disk
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/storages", tags=["storages"])
|
||||||
|
|
||||||
|
ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||||
|
STORAGES_ROOT = "/storages" # общий bind-mount корень для доп. хранилищ
|
||||||
|
HOST_STORAGES_ROOT = "/opt/nvr/storages" # он же на хосте (для команды монтирования NAS)
|
||||||
|
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────
|
||||||
|
async def _item_payload(item: StorageItem, active: str) -> dict:
|
||||||
|
nas = item.type == "nas"
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"type": item.type,
|
||||||
|
"path": item.path,
|
||||||
|
"quota_gb": item.quota_gb,
|
||||||
|
"server": item.server,
|
||||||
|
"share": item.share,
|
||||||
|
"username": item.username,
|
||||||
|
"password": item.password,
|
||||||
|
"subpath": item.subpath,
|
||||||
|
"enabled": item.enabled,
|
||||||
|
"active": item.id == active,
|
||||||
|
"mounted": is_mounted(item.path) if nas else True,
|
||||||
|
"used_bytes": await runtime.db.total_size_under(item.path),
|
||||||
|
"disk": storage_disk(item.path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _nas_mount_help(item: StorageItem) -> dict:
|
||||||
|
"""Готовая команда монтирования SMB на хосте + строка fstab (монтаж — на хосте)."""
|
||||||
|
host_path = f"{HOST_STORAGES_ROOT}/{item.id}"
|
||||||
|
src = f"//{item.server}/{item.share}"
|
||||||
|
if item.subpath:
|
||||||
|
src += "/" + item.subpath.strip("/")
|
||||||
|
cred = f"username={item.username},password={item.password}"
|
||||||
|
opts = f"{cred},uid=0,gid=0,vers=3.0,iocharset=utf8"
|
||||||
|
return {
|
||||||
|
"host_path": host_path,
|
||||||
|
"mount_cmd": f"sudo mkdir -p {host_path} && sudo mount -t cifs {src} {host_path} -o {opts}",
|
||||||
|
"fstab": f"{src} {host_path} cifs {opts},_netdev,nofail 0 0",
|
||||||
|
"note": "После монтирования на хосте выполните `docker compose up -d` (или включите "
|
||||||
|
"rshared-проброс), чтобы том стал виден в контейнере.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── список ──────────────────────────────────────────────────────
|
||||||
|
@router.get("")
|
||||||
|
async def list_storages() -> dict:
|
||||||
|
cfg = runtime.config
|
||||||
|
active = cfg.storage.active
|
||||||
|
items = [await _item_payload(s, active) for s in cfg.storage.items]
|
||||||
|
return {
|
||||||
|
"storages": items,
|
||||||
|
"active": active,
|
||||||
|
"retention_days": cfg.storage.retention_days,
|
||||||
|
"segment_seconds": cfg.storage.segment_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── политика (глобально): срок хранения и длина сегмента ─────────
|
||||||
|
@router.post("/policy")
|
||||||
|
async def update_policy(request: Request) -> dict:
|
||||||
|
require_role(request, "admin")
|
||||||
|
body = await request.json()
|
||||||
|
st = runtime.config.storage
|
||||||
|
kw: dict = {}
|
||||||
|
seg_changed = False
|
||||||
|
if "segment_seconds" in body:
|
||||||
|
try:
|
||||||
|
seg = int(body["segment_seconds"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "segment_seconds должен быть числом")
|
||||||
|
if seg not in ALLOWED_SEGMENTS:
|
||||||
|
raise HTTPException(400, "допустимо 60 / 120 / 300 / 600 / 900 секунд")
|
||||||
|
kw["segment_seconds"] = seg
|
||||||
|
seg_changed = seg != st.segment_seconds
|
||||||
|
if "retention_days" in body:
|
||||||
|
try:
|
||||||
|
rd = int(body["retention_days"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "retention_days должен быть числом")
|
||||||
|
if rd < 0 or rd > 3650:
|
||||||
|
raise HTTPException(400, "retention_days в диапазоне 0..3650")
|
||||||
|
kw["retention_days"] = rd
|
||||||
|
if not kw:
|
||||||
|
raise HTTPException(400, "ожидается segment_seconds и/или retention_days")
|
||||||
|
runtime.config.storage = replace(st, **kw)
|
||||||
|
save_config(runtime.config)
|
||||||
|
if seg_changed:
|
||||||
|
await runtime.supervisor.restart_all()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"segment_seconds": runtime.config.storage.segment_seconds,
|
||||||
|
"retention_days": runtime.config.storage.retention_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD ────────────────────────────────────────────────────────
|
||||||
|
@router.post("")
|
||||||
|
async def create_storage(request: Request) -> dict:
|
||||||
|
require_role(request, "admin")
|
||||||
|
body = await request.json()
|
||||||
|
sid = (body.get("id") or "").strip()
|
||||||
|
if not ID_RE.match(sid):
|
||||||
|
raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)")
|
||||||
|
if runtime.config.storage_by_id(sid):
|
||||||
|
raise HTTPException(409, "хранилище с таким id уже существует")
|
||||||
|
stype = body.get("type", "local")
|
||||||
|
if stype not in ("local", "nas"):
|
||||||
|
raise HTTPException(400, "type: local | nas")
|
||||||
|
name = (body.get("name") or sid).strip()
|
||||||
|
quota = max(0, int(body.get("quota_gb") or 0))
|
||||||
|
helper = None
|
||||||
|
|
||||||
|
if stype == "local":
|
||||||
|
# путь = подпапка общего корня /storages/<id>; допускаем явный path (напр. /records)
|
||||||
|
path = (body.get("path") or "").strip() or f"{STORAGES_ROOT}/{sid}"
|
||||||
|
item = StorageItem(id=sid, name=name, type="local", path=path, quota_gb=quota)
|
||||||
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(400, f"не удалось создать папку {path}: {exc}")
|
||||||
|
else:
|
||||||
|
server = (body.get("server") or "").strip()
|
||||||
|
share = (body.get("share") or "").strip()
|
||||||
|
if not server or not share:
|
||||||
|
raise HTTPException(400, "для NAS обязательны server и share")
|
||||||
|
path = f"{STORAGES_ROOT}/{sid}"
|
||||||
|
item = StorageItem(
|
||||||
|
id=sid, name=name, type="nas", path=path, quota_gb=quota,
|
||||||
|
server=server, share=share,
|
||||||
|
username=(body.get("username") or "").strip(),
|
||||||
|
password=body.get("password") or "",
|
||||||
|
subpath=(body.get("subpath") or "").strip(),
|
||||||
|
)
|
||||||
|
# создаём точку монтирования на хосте (через bind-mount /storages) — писать туда
|
||||||
|
# рекордер начнёт только когда SMB реально смонтируют (guard по os.path.ismount)
|
||||||
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
helper = _nas_mount_help(item)
|
||||||
|
|
||||||
|
new_items = runtime.config.storage.items + (item,)
|
||||||
|
runtime.config.storage = replace(runtime.config.storage, items=new_items)
|
||||||
|
save_config(runtime.config)
|
||||||
|
out = await _item_payload(item, runtime.config.storage.active)
|
||||||
|
if helper:
|
||||||
|
out["mount_help"] = helper
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{sid}")
|
||||||
|
async def update_storage(sid: str, request: Request) -> dict:
|
||||||
|
require_role(request, "admin")
|
||||||
|
item = runtime.config.storage_by_id(sid)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "storage not found")
|
||||||
|
body = await request.json()
|
||||||
|
kw: dict = {}
|
||||||
|
if "name" in body:
|
||||||
|
kw["name"] = (body.get("name") or item.name).strip()
|
||||||
|
if "quota_gb" in body:
|
||||||
|
kw["quota_gb"] = max(0, int(body.get("quota_gb") or 0))
|
||||||
|
if "enabled" in body:
|
||||||
|
kw["enabled"] = bool(body["enabled"])
|
||||||
|
for f in ("server", "share", "username", "subpath"): # метаданные NAS
|
||||||
|
if f in body:
|
||||||
|
kw[f] = (body.get(f) or "").strip()
|
||||||
|
if "password" in body:
|
||||||
|
kw["password"] = body.get("password") or ""
|
||||||
|
if not kw:
|
||||||
|
raise HTTPException(400, "нет полей для изменения")
|
||||||
|
# id/type/path неизменны (привязаны к записям на диске → не ломаем архив)
|
||||||
|
new = replace(item, **kw)
|
||||||
|
items = list(runtime.config.storage.items)
|
||||||
|
idx = next(i for i, s in enumerate(items) if s.id == sid)
|
||||||
|
items[idx] = new
|
||||||
|
runtime.config.storage = replace(runtime.config.storage, items=tuple(items))
|
||||||
|
save_config(runtime.config)
|
||||||
|
# quota применится при следующем проходе retention; path не менялся → рекордеры не трогаем
|
||||||
|
out = await _item_payload(new, runtime.config.storage.active)
|
||||||
|
if new.type == "nas":
|
||||||
|
out["mount_help"] = _nas_mount_help(new)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sid}")
|
||||||
|
async def delete_storage(sid: str, request: Request) -> dict:
|
||||||
|
"""Убирает хранилище из списка. Файлы и записи в БД НЕ трогаются (архив сохраняется)."""
|
||||||
|
require_role(request, "admin")
|
||||||
|
if not runtime.config.storage_by_id(sid):
|
||||||
|
raise HTTPException(404, "storage not found")
|
||||||
|
if sid == runtime.config.storage.active:
|
||||||
|
raise HTTPException(409, "нельзя удалить активное хранилище — сначала выберите другое")
|
||||||
|
if len(runtime.config.storage.items) <= 1:
|
||||||
|
raise HTTPException(409, "нельзя удалить последнее хранилище")
|
||||||
|
items = tuple(s for s in runtime.config.storage.items if s.id != sid)
|
||||||
|
runtime.config.storage = replace(runtime.config.storage, items=items)
|
||||||
|
save_config(runtime.config)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/activate")
|
||||||
|
async def activate_storage(sid: str, request: Request) -> dict:
|
||||||
|
"""Делает хранилище активным (новые записи идут туда). Перезапускает рекордеры."""
|
||||||
|
require_role(request, "admin")
|
||||||
|
item = runtime.config.storage_by_id(sid)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "storage not found")
|
||||||
|
runtime.config.storage = replace(runtime.config.storage, active=sid)
|
||||||
|
save_config(runtime.config)
|
||||||
|
await runtime.supervisor.restart_all() # репойнт рекордеров на новый путь
|
||||||
|
mounted = is_mounted(item.path) if item.type == "nas" else True
|
||||||
|
return {"ok": True, "active": sid, "mounted": mounted}
|
||||||
@@ -3,16 +3,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from ..auth import require_role
|
|
||||||
from ..config import Storage, save_config
|
|
||||||
from ..runtime import runtime
|
from ..runtime import runtime
|
||||||
from ..services.system import system_stats
|
from ..services.system import system_stats
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["system"])
|
router = APIRouter(prefix="/api", tags=["system"])
|
||||||
|
|
||||||
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
|
||||||
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
|
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
|
||||||
|
|
||||||
|
|
||||||
@@ -36,25 +33,3 @@ async def get_system() -> dict:
|
|||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/storage")
|
|
||||||
async def update_storage(request: Request) -> dict:
|
|
||||||
"""Смена размера сегмента записи (1/5/10/15 мин). Перезапускает рекордеры."""
|
|
||||||
require_role(request, "admin")
|
|
||||||
body = await request.json()
|
|
||||||
try:
|
|
||||||
seg = int(body.get("segment_seconds"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise HTTPException(400, "segment_seconds должен быть числом")
|
|
||||||
if seg not in ALLOWED_SEGMENTS:
|
|
||||||
raise HTTPException(400, "допустимо 60 / 300 / 600 / 900 секунд")
|
|
||||||
|
|
||||||
st = runtime.config.storage
|
|
||||||
runtime.config.storage = Storage(
|
|
||||||
path=st.path, quota_gb=st.quota_gb,
|
|
||||||
retention_days=st.retention_days, segment_seconds=seg,
|
|
||||||
)
|
|
||||||
save_config(runtime.config)
|
|
||||||
await runtime.supervisor.restart_all()
|
|
||||||
return {"segment_seconds": seg}
|
|
||||||
|
|||||||
+111
-5
@@ -29,12 +29,45 @@ class Camera:
|
|||||||
return self.rtsp_url(self.main_path)
|
return self.rtsp_url(self.main_path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StorageItem:
|
||||||
|
"""Одно хранилище записи: локальная папка или сетевой диск (SMB/NAS).
|
||||||
|
|
||||||
|
`path` — путь ВНУТРИ контейнера, куда пишутся сегменты. `quota_gb` — лимит
|
||||||
|
размера ЭТОГО хранилища (0 = без лимита). Для NAS поля server/share/username/
|
||||||
|
password/subpath — метаданные (монтаж делается на хосте, backend ими не
|
||||||
|
монтирует, только показывает статус и генерирует команду mount).
|
||||||
|
"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str # "local" | "nas"
|
||||||
|
path: str
|
||||||
|
quota_gb: int = 0
|
||||||
|
server: str = ""
|
||||||
|
share: str = ""
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
subpath: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Storage:
|
class Storage:
|
||||||
path: str = "/records"
|
"""Глобальная политика записи + список хранилищ и id активного.
|
||||||
quota_gb: int = 50
|
|
||||||
|
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
|
||||||
|
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
|
||||||
|
"""
|
||||||
|
active: str = "local"
|
||||||
retention_days: int = 7
|
retention_days: int = 7
|
||||||
segment_seconds: int = 600
|
segment_seconds: int = 600
|
||||||
|
items: tuple[StorageItem, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
|
||||||
|
_DEFAULT_STORAGE = StorageItem(
|
||||||
|
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -55,6 +88,21 @@ class Config:
|
|||||||
def enabled_cameras(self) -> list[Camera]:
|
def enabled_cameras(self) -> list[Camera]:
|
||||||
return [c for c in self.cameras if c.enabled]
|
return [c for c in self.cameras if c.enabled]
|
||||||
|
|
||||||
|
# ── хранилища ───────────────────────────────────────────────
|
||||||
|
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||||
|
return next((s for s in self.storage.items if s.id == sid), None)
|
||||||
|
|
||||||
|
def active_storage(self) -> StorageItem:
|
||||||
|
"""Хранилище, куда пишутся новые записи (fallback — первый/дефолтный)."""
|
||||||
|
return (
|
||||||
|
self.storage_by_id(self.storage.active)
|
||||||
|
or (self.storage.items[0] if self.storage.items else _DEFAULT_STORAGE)
|
||||||
|
)
|
||||||
|
|
||||||
|
def storage_paths(self) -> list[str]:
|
||||||
|
"""Пути всех хранилищ — индексатор сканирует их все (старые записи не теряются)."""
|
||||||
|
return [s.path for s in self.storage.items] or [_DEFAULT_STORAGE.path]
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | None = None) -> Config:
|
def load_config(path: str | None = None) -> Config:
|
||||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||||
@@ -69,7 +117,7 @@ def load_config(path: str | None = None) -> Config:
|
|||||||
except (OSError, yaml.YAMLError):
|
except (OSError, yaml.YAMLError):
|
||||||
raw = {}
|
raw = {}
|
||||||
|
|
||||||
storage = Storage(**(raw.get("storage") or {}))
|
storage = _storage_from_raw(raw.get("storage") or {})
|
||||||
live = Live(**(raw.get("live") or {}))
|
live = Live(**(raw.get("live") or {}))
|
||||||
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
||||||
return Config(storage=storage, live=live, cameras=cameras)
|
return Config(storage=storage, live=live, cameras=cameras)
|
||||||
@@ -96,6 +144,49 @@ def _camera_from_raw(c: dict) -> Camera:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_item_from_raw(s: dict) -> StorageItem:
|
||||||
|
return StorageItem(
|
||||||
|
id=s["id"],
|
||||||
|
name=s.get("name", s["id"]),
|
||||||
|
type=s.get("type", "local"),
|
||||||
|
path=s["path"],
|
||||||
|
quota_gb=int(s.get("quota_gb", 0) or 0),
|
||||||
|
server=s.get("server", "") or "",
|
||||||
|
share=s.get("share", "") or "",
|
||||||
|
username=s.get("username", "") or "",
|
||||||
|
password=s.get("password", "") or "",
|
||||||
|
subpath=s.get("subpath", "") or "",
|
||||||
|
enabled=bool(s.get("enabled", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_from_raw(raw: dict) -> Storage:
|
||||||
|
# Новая схема: есть список items.
|
||||||
|
if raw.get("items"):
|
||||||
|
items = tuple(_storage_item_from_raw(s) for s in raw["items"])
|
||||||
|
active = raw.get("active") or (items[0].id if items else "local")
|
||||||
|
return Storage(
|
||||||
|
active=active,
|
||||||
|
retention_days=int(raw.get("retention_days", 7)),
|
||||||
|
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
# Миграция со старой одиночной схемы {path, quota_gb, retention_days, segment_seconds}:
|
||||||
|
# один локальный item, старый глобальный quota → квота этого хранилища.
|
||||||
|
legacy_path = raw.get("path") or "/records"
|
||||||
|
legacy_quota = int(raw.get("quota_gb", 30) or 30)
|
||||||
|
local = StorageItem(
|
||||||
|
id="local", name="Локальное хранилище", type="local",
|
||||||
|
path=legacy_path, quota_gb=legacy_quota,
|
||||||
|
)
|
||||||
|
return Storage(
|
||||||
|
active="local",
|
||||||
|
retention_days=int(raw.get("retention_days", 7)),
|
||||||
|
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||||
|
items=(local,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def camera_to_yaml(c: Camera) -> dict:
|
def camera_to_yaml(c: Camera) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
@@ -110,15 +201,30 @@ def camera_to_yaml(c: Camera) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def storage_item_to_yaml(s: StorageItem) -> dict:
|
||||||
|
d = {
|
||||||
|
"id": s.id, "name": s.name, "type": s.type,
|
||||||
|
"path": s.path, "quota_gb": s.quota_gb,
|
||||||
|
}
|
||||||
|
if s.type == "nas":
|
||||||
|
d.update({
|
||||||
|
"server": s.server, "share": s.share, "username": s.username,
|
||||||
|
"password": s.password, "subpath": s.subpath,
|
||||||
|
})
|
||||||
|
if not s.enabled:
|
||||||
|
d["enabled"] = False
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def save_config(config: Config, path: str | None = None) -> None:
|
def save_config(config: Config, path: str | None = None) -> None:
|
||||||
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
||||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||||
data = {
|
data = {
|
||||||
"storage": {
|
"storage": {
|
||||||
"path": config.storage.path,
|
"active": config.storage.active,
|
||||||
"quota_gb": config.storage.quota_gb,
|
|
||||||
"retention_days": config.storage.retention_days,
|
"retention_days": config.storage.retention_days,
|
||||||
"segment_seconds": config.storage.segment_seconds,
|
"segment_seconds": config.storage.segment_seconds,
|
||||||
|
"items": [storage_item_to_yaml(s) for s in config.storage.items],
|
||||||
},
|
},
|
||||||
"live": {"go2rtc_url": config.live.go2rtc_url},
|
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import aiosqlite
|
|||||||
|
|
||||||
from .models import Recording
|
from .models import Recording
|
||||||
|
|
||||||
|
|
||||||
|
def _like_prefix(prefix: str) -> str:
|
||||||
|
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
|
||||||
|
p = prefix.rstrip("/") + "/"
|
||||||
|
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return p + "%"
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS recordings (
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -105,6 +113,24 @@ class Database:
|
|||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
return [Recording(**dict(r)) for r in rows]
|
return [Recording(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
# ── per-storage (по префиксу пути) — для квоты на каждое хранилище ──
|
||||||
|
async def total_size_under(self, prefix: str) -> int:
|
||||||
|
cur = await self.conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(size_bytes), 0) FROM recordings WHERE path LIKE ? ESCAPE '\\'",
|
||||||
|
(_like_prefix(prefix),),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
async def oldest_recordings_under(self, prefix: str, limit: int = 50) -> list[Recording]:
|
||||||
|
cur = await self.conn.execute(
|
||||||
|
"SELECT * FROM recordings WHERE path LIKE ? ESCAPE '\\' "
|
||||||
|
"ORDER BY started_at ASC LIMIT ?",
|
||||||
|
(_like_prefix(prefix), limit),
|
||||||
|
)
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
return [Recording(**dict(r)) for r in rows]
|
||||||
|
|
||||||
async def recordings_before(self, ts: int) -> list[Recording]:
|
async def recordings_before(self, ts: int) -> list[Recording]:
|
||||||
cur = await self.conn.execute(
|
cur = await self.conn.execute(
|
||||||
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
||||||
|
|||||||
+8
-4
@@ -17,7 +17,7 @@ from .recorder.supervisor import Supervisor
|
|||||||
from .services.retention import Retention
|
from .services.retention import Retention
|
||||||
from .runtime import runtime
|
from .runtime import runtime
|
||||||
from . import auth, prefs
|
from . import auth, prefs
|
||||||
from .api import cameras, recordings, system, ws
|
from .api import cameras, recordings, storages, system, ws
|
||||||
from .api.ws import ConnectionManager
|
from .api.ws import ConnectionManager
|
||||||
from .services import go2rtc
|
from .services import go2rtc
|
||||||
|
|
||||||
@@ -46,15 +46,18 @@ async def lifespan(app: FastAPI):
|
|||||||
runtime.indexer = Indexer(runtime.config, runtime.db)
|
runtime.indexer = Indexer(runtime.config, runtime.db)
|
||||||
runtime.retention = Retention(runtime.config, runtime.db)
|
runtime.retention = Retention(runtime.config, runtime.db)
|
||||||
|
|
||||||
os.makedirs(runtime.config.storage.path, exist_ok=True)
|
# каталоги локальных хранилищ; NAS не создаём — ждём монтаж на хосте (guard в recorder_task)
|
||||||
|
for st in runtime.config.storage.items:
|
||||||
|
if st.type == "local":
|
||||||
|
os.makedirs(st.path, exist_ok=True)
|
||||||
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
|
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
|
||||||
runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc
|
runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc
|
||||||
runtime.supervisor.start_all()
|
runtime.supervisor.start_all()
|
||||||
runtime.indexer.start()
|
runtime.indexer.start()
|
||||||
runtime.retention.start()
|
runtime.retention.start()
|
||||||
runtime.reconciler.start()
|
runtime.reconciler.start()
|
||||||
log.info("NVR запущен: камер=%d, хранилище=%s",
|
log.info("NVR запущен: камер=%d, активное хранилище=%s",
|
||||||
len(runtime.config.cameras), runtime.config.storage.path)
|
len(runtime.config.cameras), runtime.config.active_storage().path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -99,6 +102,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(prefs.router)
|
app.include_router(prefs.router)
|
||||||
app.include_router(cameras.router)
|
app.include_router(cameras.router)
|
||||||
app.include_router(recordings.router)
|
app.include_router(recordings.router)
|
||||||
|
app.include_router(storages.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from ..config import Camera, Storage
|
from ..config import Camera
|
||||||
|
|
||||||
|
|
||||||
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
|
def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
||||||
|
input_url: str | None = None) -> list[str]:
|
||||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||||
|
|
||||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||||
|
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||||
"""
|
"""
|
||||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
||||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
||||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
||||||
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
||||||
return [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
@@ -29,7 +31,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
|
|||||||
"-an", # звук не пишем (камеры обычно без него)
|
"-an", # звук не пишем (камеры обычно без него)
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-f", "segment",
|
"-f", "segment",
|
||||||
"-segment_time", str(storage.segment_seconds),
|
"-segment_time", str(segment_seconds),
|
||||||
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
||||||
"-segment_clocktime_offset", "0",
|
"-segment_clocktime_offset", "0",
|
||||||
"-segment_format", "mp4",
|
"-segment_format", "mp4",
|
||||||
|
|||||||
@@ -80,24 +80,28 @@ class Indexer:
|
|||||||
await asyncio.sleep(SCAN_INTERVAL)
|
await asyncio.sleep(SCAN_INTERVAL)
|
||||||
|
|
||||||
async def scan_once(self) -> int:
|
async def scan_once(self) -> int:
|
||||||
root = self.config.storage.path
|
active_path = self.config.active_storage().path
|
||||||
by_cam = _scan_segments(root)
|
|
||||||
known = await self.db.known_paths()
|
known = await self.db.known_paths()
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
for cam_id, files in by_cam.items():
|
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
||||||
# последний файл может писаться прямо сейчас — не индексируем
|
for root in self.config.storage_paths():
|
||||||
complete = files[:-1] if len(files) > 1 else []
|
by_cam = _scan_segments(root)
|
||||||
for path in complete:
|
is_active = (root == active_path)
|
||||||
if path in known:
|
for cam_id, files in by_cam.items():
|
||||||
continue
|
# в активном хранилище последний файл ещё пишется — пропускаем;
|
||||||
duration, size = await probe_duration(path)
|
# в неактивных все сегменты завершены, индексируем целиком.
|
||||||
if size is None or size == 0:
|
complete = files[:-1] if is_active else files
|
||||||
continue
|
for path in complete:
|
||||||
await self.db.add_recording(
|
if path in known:
|
||||||
cam_id, path, _parse_started_at(path), duration, size
|
continue
|
||||||
)
|
duration, size = await probe_duration(path)
|
||||||
added += 1
|
if size is None or size == 0:
|
||||||
|
continue
|
||||||
|
await self.db.add_recording(
|
||||||
|
cam_id, path, _parse_started_at(path), duration, size
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
|
||||||
# уборка пропавших файлов
|
# уборка пропавших файлов
|
||||||
for path in known:
|
for path in known:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from ..config import Camera, Storage
|
from ..config import Camera, StorageItem
|
||||||
from ..models import CameraState, CameraStatus
|
from ..models import CameraState, CameraStatus
|
||||||
from .ffmpeg import record_args
|
from .ffmpeg import record_args
|
||||||
|
|
||||||
@@ -21,9 +21,11 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
|
|||||||
class RecorderTask:
|
class RecorderTask:
|
||||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||||
|
|
||||||
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
|
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
|
||||||
|
on_status=None, input_url: str | None = None):
|
||||||
self.cam = cam
|
self.cam = cam
|
||||||
self.storage = storage
|
self.store = store # активное хранилище (куда пишем)
|
||||||
|
self.segment_seconds = segment_seconds
|
||||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||||
self.status = CameraStatus(
|
self.status = CameraStatus(
|
||||||
@@ -69,11 +71,27 @@ class RecorderTask:
|
|||||||
# ── основной цикл ───────────────────────────────────────────
|
# ── основной цикл ───────────────────────────────────────────
|
||||||
async def _run(self) -> None:
|
async def _run(self) -> None:
|
||||||
backoff = BACKOFF_START
|
backoff = BACKOFF_START
|
||||||
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
|
|
||||||
|
|
||||||
while not self._stopping:
|
while not self._stopping:
|
||||||
|
# NAS: пишем только если том реально смонтирован на хосте. Иначе запись
|
||||||
|
# ушла бы в overlay контейнера (потеря при рестарте) — ждём монтаж, каталог
|
||||||
|
# тоже не создаём.
|
||||||
|
if self.store.type == "nas" and not os.path.ismount(self.store.path):
|
||||||
|
self._set_state(CameraState.RETRYING, "NAS не смонтирован")
|
||||||
|
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.join(self.store.path, self.cam.id), exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
|
||||||
|
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
self._set_state(CameraState.STARTING)
|
self._set_state(CameraState.STARTING)
|
||||||
args = record_args(self.cam, self.storage, self.input_url)
|
args = record_args(self.cam, self.store.path, self.segment_seconds, self.input_url)
|
||||||
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||||
try:
|
try:
|
||||||
self._proc = await asyncio.create_subprocess_exec(
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -21,10 +21,15 @@ class Supervisor:
|
|||||||
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
||||||
return go2rtc.restream_main_url(self.config, cam.id)
|
return go2rtc.restream_main_url(self.config, cam.id)
|
||||||
|
|
||||||
|
def _new_task(self, cam: Camera) -> RecorderTask:
|
||||||
|
# пишем в активное хранилище; смена активного → restart_all репойнтит все задачи
|
||||||
|
active = self.config.active_storage()
|
||||||
|
return RecorderTask(cam, active, self.config.storage.segment_seconds,
|
||||||
|
on_status=self.on_status, input_url=self._input_url(cam))
|
||||||
|
|
||||||
def start_all(self) -> None:
|
def start_all(self) -> None:
|
||||||
for cam in self.config.cameras:
|
for cam in self.config.cameras:
|
||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = self._new_task(cam)
|
||||||
input_url=self._input_url(cam))
|
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled and cam.record:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
@@ -57,8 +62,7 @@ class Supervisor:
|
|||||||
|
|
||||||
def add_camera(self, cam: Camera) -> None:
|
def add_camera(self, cam: Camera) -> None:
|
||||||
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = self._new_task(cam)
|
||||||
input_url=self._input_url(cam))
|
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled and cam.record:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
|
|||||||
@@ -61,20 +61,22 @@ class Retention:
|
|||||||
return len(old)
|
return len(old)
|
||||||
|
|
||||||
async def _enforce_quota(self) -> int:
|
async def _enforce_quota(self) -> int:
|
||||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
|
||||||
if not quota:
|
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
|
||||||
return 0
|
|
||||||
removed = 0
|
removed = 0
|
||||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
for item in self.config.storage.items:
|
||||||
while await self.db.total_size() > quota:
|
if not item.quota_gb:
|
||||||
batch = await self.db.oldest_recordings(limit=20)
|
continue
|
||||||
if not batch:
|
quota = item.quota_gb * 1024 ** 3
|
||||||
break
|
while await self.db.total_size_under(item.path) > quota:
|
||||||
for rec in batch:
|
batch = await self.db.oldest_recordings_under(item.path, limit=20)
|
||||||
await self._remove(rec.id, rec.path)
|
if not batch:
|
||||||
removed += 1
|
|
||||||
if await self.db.total_size() <= quota:
|
|
||||||
break
|
break
|
||||||
|
for rec in batch:
|
||||||
|
await self._remove(rec.id, rec.path)
|
||||||
|
removed += 1
|
||||||
|
if await self.db.total_size_under(item.path) <= quota:
|
||||||
|
break
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
async def _remove(self, rec_id: int, path: str) -> None:
|
async def _remove(self, rec_id: int, path: str) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -11,19 +12,31 @@ from ..config import Config
|
|||||||
_START = time.time()
|
_START = time.time()
|
||||||
|
|
||||||
|
|
||||||
def system_stats(config: Config) -> dict:
|
def storage_disk(path: str) -> dict:
|
||||||
path = config.storage.path
|
"""Использование диска, на котором лежит path (для одного хранилища)."""
|
||||||
try:
|
try:
|
||||||
usage = shutil.disk_usage(path)
|
usage = shutil.disk_usage(path)
|
||||||
disk = {
|
return {
|
||||||
"total_gb": round(usage.total / 1024 ** 3, 1),
|
"total_gb": round(usage.total / 1024 ** 3, 1),
|
||||||
"used_gb": round(usage.used / 1024 ** 3, 1),
|
"used_gb": round(usage.used / 1024 ** 3, 1),
|
||||||
"free_gb": round(usage.free / 1024 ** 3, 1),
|
"free_gb": round(usage.free / 1024 ** 3, 1),
|
||||||
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||||
}
|
}
|
||||||
except OSError:
|
except OSError:
|
||||||
disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
return {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def is_mounted(path: str) -> bool:
|
||||||
|
"""Точка монтирования? (для NAS — признак, что том реально подключён)."""
|
||||||
|
try:
|
||||||
|
return os.path.ismount(path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def system_stats(config: Config) -> dict:
|
||||||
|
active = config.active_storage()
|
||||||
|
path = active.path
|
||||||
vm = psutil.virtual_memory()
|
vm = psutil.virtual_memory()
|
||||||
return {
|
return {
|
||||||
"uptime_s": int(time.time() - _START),
|
"uptime_s": int(time.time() - _START),
|
||||||
@@ -32,8 +45,8 @@ def system_stats(config: Config) -> dict:
|
|||||||
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
|
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
|
||||||
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
||||||
"storage_path": path,
|
"storage_path": path,
|
||||||
"disk": disk,
|
"disk": storage_disk(path),
|
||||||
"quota_gb": config.storage.quota_gb,
|
"quota_gb": active.quota_gb,
|
||||||
"retention_days": config.storage.retention_days,
|
"retention_days": config.storage.retention_days,
|
||||||
"segment_seconds": config.storage.segment_seconds,
|
"segment_seconds": config.storage.segment_seconds,
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -20,10 +20,13 @@ services:
|
|||||||
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
||||||
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
||||||
- ./data:/data # SQLite-индекс архива
|
- ./data:/data # SQLite-индекс архива
|
||||||
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
|
# Запись. ./records — дефолтное локальное хранилище (id=local).
|
||||||
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
|
|
||||||
# - /mnt/nas/cctv:/records
|
|
||||||
- ./records:/records
|
- ./records:/records
|
||||||
|
# Общий корень доп. хранилищ (вкладка «Хранилища»): локальные подпапки
|
||||||
|
# /storages/<id> и точки монтирования NAS. NAS монтируется на ХОСТЕ в
|
||||||
|
# ./storages/<id>; после `mount` на хосте выполните `docker compose up -d`,
|
||||||
|
# чтобы том стал виден здесь (или добавьте :rshared для live-подхвата).
|
||||||
|
- ./storages:/storages
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
NVR_CONFIG: /app/cameras.yaml
|
NVR_CONFIG: /app/cameras.yaml
|
||||||
|
|||||||
+81
-15
@@ -34,6 +34,7 @@
|
|||||||
<main class="settings-main">
|
<main class="settings-main">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
||||||
|
<button class="tab" data-tab="storage" data-min-role="admin">Хранилища</button>
|
||||||
<button class="tab" data-tab="layout">Раскладка</button>
|
<button class="tab" data-tab="layout">Раскладка</button>
|
||||||
<button class="tab" data-tab="player" data-min-role="operator">Плеер</button>
|
<button class="tab" data-tab="player" data-min-role="operator">Плеер</button>
|
||||||
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
|
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
|
||||||
@@ -62,25 +63,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ВКЛАДКА: Хранилища (управление записью) -->
|
||||||
|
<div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Хранилища записи</h2>
|
||||||
|
<button id="stor-add">+ Добавить хранилище</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-bottom:12px">
|
||||||
|
Активное хранилище — куда пишутся новые записи. Квота задаётся на каждое хранилище
|
||||||
|
(0 = без лимита) и правится прямо в таблице. После смены активного старые записи
|
||||||
|
остаются доступны в Архиве. Удаление хранилища убирает его из списка — файлы записей
|
||||||
|
НЕ удаляются.
|
||||||
|
</div>
|
||||||
|
<table class="cams">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:74px">Активно</th>
|
||||||
|
<th style="width:90px">ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th style="width:56px">Тип</th>
|
||||||
|
<th>Путь</th>
|
||||||
|
<th style="width:130px">Квота, ГБ</th>
|
||||||
|
<th style="width:150px">Занято / диск</th>
|
||||||
|
<th style="width:130px">Статус</th>
|
||||||
|
<th style="width:48px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="stor-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="muted" id="stor-msg" style="margin-top:10px;font-size:12px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- форма добавления (скрыта по умолчанию) -->
|
||||||
|
<div class="card" id="stor-form-card" hidden>
|
||||||
|
<h2>Новое хранилище</h2>
|
||||||
|
<div class="cam-edit-grid">
|
||||||
|
<label>ID<input type="text" id="sf-id" placeholder="arch2"></label>
|
||||||
|
<label>Название<input type="text" id="sf-name" placeholder="Архив 2"></label>
|
||||||
|
<label>Тип
|
||||||
|
<select id="sf-type">
|
||||||
|
<option value="local">Локальное (папка)</option>
|
||||||
|
<option value="nas">NAS (SMB)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Квота, ГБ (0 = без лимита)<input type="number" id="sf-quota" min="0" value="0"></label>
|
||||||
|
<label class="sf-local">Папка<input type="text" id="sf-path" placeholder="/storages/arch2 (по умолчанию)"></label>
|
||||||
|
<label class="sf-nas" hidden>SMB сервер (IP)<input type="text" id="sf-server" placeholder="192.168.1.10"></label>
|
||||||
|
<label class="sf-nas" hidden>Шара (share)<input type="text" id="sf-share" placeholder="cctv"></label>
|
||||||
|
<label class="sf-nas" hidden>Логин<input type="text" id="sf-user"></label>
|
||||||
|
<label class="sf-nas" hidden>Пароль<input type="password" id="sf-pass"></label>
|
||||||
|
<label class="sf-nas" hidden>Подпапка (необяз.)<input type="text" id="sf-subpath" placeholder="cam-archive"></label>
|
||||||
|
</div>
|
||||||
|
<div class="cam-edit-foot">
|
||||||
|
<span class="err" id="sf-err"></span>
|
||||||
|
<button id="sf-save" class="primary">Создать</button>
|
||||||
|
<button id="sf-cancel">Отмена</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted" id="sf-mount" style="margin-top:10px;font-size:12px;white-space:pre-wrap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Политика записи <span class="muted" style="font-weight:400;font-size:12px">— общая для всех хранилищ</span></h2>
|
||||||
|
<div class="cam-edit-grid">
|
||||||
|
<label>Длина сегмента
|
||||||
|
<select id="pol-segment">
|
||||||
|
<option value="60">1 минута</option>
|
||||||
|
<option value="120">2 минуты</option>
|
||||||
|
<option value="300">5 минут</option>
|
||||||
|
<option value="600">10 минут</option>
|
||||||
|
<option value="900">15 минут</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Срок хранения, дней (0 = не удалять по возрасту)<input type="number" id="pol-retention" min="0" max="3650"></label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
|
||||||
|
<div class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ВКЛАДКА 2: Система -->
|
<!-- ВКЛАДКА 2: Система -->
|
||||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="card" data-min-role="admin">
|
|
||||||
<h2>Запись</h2>
|
|
||||||
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
|
|
||||||
<select id="segment-size" style="margin-top:6px;display:block">
|
|
||||||
<option value="60">1 минута</option>
|
|
||||||
<option value="120">2 минуты</option>
|
|
||||||
<option value="300">5 минут</option>
|
|
||||||
<option value="600">10 минут</option>
|
|
||||||
<option value="900">15 минут</option>
|
|
||||||
</select>
|
|
||||||
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
|
|
||||||
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Хранилище</h2>
|
<h2>Хранилище <span class="muted" style="font-weight:400;font-size:12px">— активное (управление во вкладке «Хранилища»)</span></h2>
|
||||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+184
-26
@@ -1441,7 +1441,7 @@ const Settings = {
|
|||||||
if (meEl && CURRENT_USER)
|
if (meEl && CURRENT_USER)
|
||||||
meEl.innerHTML = `Вы вошли как <b>${CURRENT_USER.username}</b> — ` +
|
meEl.innerHTML = `Вы вошли как <b>${CURRENT_USER.username}</b> — ` +
|
||||||
(ROLE_RU[CURRENT_USER.role] || CURRENT_USER.role);
|
(ROLE_RU[CURRENT_USER.role] || CURRENT_USER.role);
|
||||||
await Promise.all([this.loadSystem(), this.loadPrefs(), this.loadStorage()]);
|
await Promise.all([this.loadSystem(), this.loadPrefs()]);
|
||||||
if (roleAtLeast("superadmin")) this.loadOptim();
|
if (roleAtLeast("superadmin")) this.loadOptim();
|
||||||
setInterval(() => this.loadSystem(), 5000);
|
setInterval(() => this.loadSystem(), 5000);
|
||||||
},
|
},
|
||||||
@@ -1484,31 +1484,6 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadStorage() {
|
|
||||||
const s = await api("/api/system");
|
|
||||||
const sel = document.getElementById("segment-size");
|
|
||||||
const saved = document.getElementById("segment-saved");
|
|
||||||
const btn = document.getElementById("segment-save");
|
|
||||||
sel.value = String(s.segment_seconds);
|
|
||||||
btn.onclick = async () => {
|
|
||||||
btn.disabled = true; saved.textContent = "Сохранение…";
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/storage", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ segment_seconds: +sel.value }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
saved.textContent = "Сохранено ✓ рекордеры перезапущены";
|
|
||||||
setTimeout(() => { saved.textContent = ""; }, 3000);
|
|
||||||
} else {
|
|
||||||
const d = await r.json().catch(() => ({}));
|
|
||||||
saved.textContent = d.detail || ("Ошибка " + r.status);
|
|
||||||
}
|
|
||||||
} finally { btn.disabled = false; }
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
trRowHtml(pr) { // ряд параметров одного профиля
|
trRowHtml(pr) { // ряд параметров одного профиля
|
||||||
const sel = (f, opts) => `<select data-f="${f}">` + opts.map(([v, t]) =>
|
const sel = (f, opts) => `<select data-f="${f}">` + opts.map(([v, t]) =>
|
||||||
`<option value="${v}"${String(pr[f]) === String(v) ? " selected" : ""}>${t}</option>`).join("") + `</select>`;
|
`<option value="${v}"${String(pr[f]) === String(v) ? " selected" : ""}>${t}</option>`).join("") + `</select>`;
|
||||||
@@ -1618,6 +1593,188 @@ const Settings = {
|
|||||||
kv(k, v) { return `<div class="k">${k}</div><div class="v">${v}</div>`; },
|
kv(k, v) { return `<div class="k">${k}</div><div class="v">${v}</div>`; },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// Вкладка ХРАНИЛИЩА (несколько хранилищ + выбор активного)
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
const Storages = {
|
||||||
|
data: { storages: [], active: "", retention_days: 7, segment_seconds: 600 },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const addBtn = document.getElementById("stor-add");
|
||||||
|
if (!addBtn) return; // нет прав / не на странице настроек
|
||||||
|
addBtn.onclick = () => this.toggleForm(true);
|
||||||
|
const cancel = document.getElementById("sf-cancel");
|
||||||
|
if (cancel) cancel.onclick = () => this.toggleForm(false);
|
||||||
|
const typeSel = document.getElementById("sf-type");
|
||||||
|
if (typeSel) typeSel.onchange = () => this.syncFormType();
|
||||||
|
const save = document.getElementById("sf-save");
|
||||||
|
if (save) save.onclick = () => this.create();
|
||||||
|
const pol = document.getElementById("pol-save");
|
||||||
|
if (pol) pol.onclick = () => this.savePolicy();
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
esc(s) { return String(s == null ? "" : s).replace(/"/g, """); },
|
||||||
|
|
||||||
|
toggleForm(show) {
|
||||||
|
const card = document.getElementById("stor-form-card");
|
||||||
|
if (!card) return;
|
||||||
|
card.hidden = !show;
|
||||||
|
if (show) {
|
||||||
|
["sf-id", "sf-name", "sf-path", "sf-server", "sf-share", "sf-user", "sf-pass", "sf-subpath"]
|
||||||
|
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||||
|
document.getElementById("sf-quota").value = "0";
|
||||||
|
document.getElementById("sf-type").value = "local";
|
||||||
|
document.getElementById("sf-err").textContent = "";
|
||||||
|
document.getElementById("sf-mount").textContent = "";
|
||||||
|
this.syncFormType();
|
||||||
|
document.getElementById("sf-id").focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncFormType() {
|
||||||
|
const nas = document.getElementById("sf-type").value === "nas";
|
||||||
|
document.querySelectorAll(".sf-nas").forEach((el) => { el.hidden = !nas; });
|
||||||
|
document.querySelectorAll(".sf-local").forEach((el) => { el.hidden = nas; });
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
let d;
|
||||||
|
try { d = await api("/api/storages"); } catch (e) { return; }
|
||||||
|
this.data = d;
|
||||||
|
this.render();
|
||||||
|
const seg = document.getElementById("pol-segment");
|
||||||
|
if (seg) seg.value = String(d.segment_seconds);
|
||||||
|
const ret = document.getElementById("pol-retention");
|
||||||
|
if (ret) ret.value = String(d.retention_days);
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const tb = document.getElementById("stor-rows");
|
||||||
|
if (!tb) return;
|
||||||
|
const v = (x) => this.esc(x);
|
||||||
|
tb.innerHTML = "";
|
||||||
|
(this.data.storages || []).forEach((s) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.id = s.id;
|
||||||
|
const typeRu = s.type === "nas" ? "NAS" : "локал";
|
||||||
|
const used = fmtSize(s.used_bytes || 0);
|
||||||
|
const disk = s.disk ? `${s.disk.used_gb} / ${s.disk.total_gb} ГБ` : "—";
|
||||||
|
const statusCell = s.type === "nas"
|
||||||
|
? `<span class="badge"><span class="${dotClass(s.mounted ? "online" : "offline")}"></span>${s.mounted ? "смонтировано" : "не смонтировано"}</span>`
|
||||||
|
: `<span class="muted">—</span>`;
|
||||||
|
const activeCell = s.active
|
||||||
|
? `<span class="badge badge-active">активно</span>`
|
||||||
|
: `<button data-act="activate" data-id="${v(s.id)}">Выбрать</button>`;
|
||||||
|
tr.innerHTML =
|
||||||
|
`<td>${activeCell}</td>` +
|
||||||
|
`<td class="muted">${v(s.id)}</td>` +
|
||||||
|
`<td>${v(s.name)}</td>` +
|
||||||
|
`<td class="muted">${typeRu}</td>` +
|
||||||
|
`<td class="muted" title="${v(s.path)}">${v(s.path)}</td>` +
|
||||||
|
`<td><span class="q-cell"><input type="number" class="q-input" data-id="${v(s.id)}" min="0" value="${s.quota_gb}">` +
|
||||||
|
`<button data-act="save-quota" data-id="${v(s.id)}" title="Сохранить квоту">✓</button></span></td>` +
|
||||||
|
`<td class="muted">${used} / ${disk}</td>` +
|
||||||
|
`<td>${statusCell}</td>` +
|
||||||
|
`<td class="actions">${s.active ? "" : `<button data-act="delete" data-id="${v(s.id)}" title="Убрать из списка (файлы не удаляются)">✕</button>`}</td>`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
tb.onclick = (e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.dataset.id, act = btn.dataset.act;
|
||||||
|
if (act === "activate") this.activate(id);
|
||||||
|
else if (act === "delete") this.remove(id);
|
||||||
|
else if (act === "save-quota") this.saveQuota(id);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async activate(id) {
|
||||||
|
const s = (this.data.storages || []).find((x) => x.id === id);
|
||||||
|
if (s && s.type === "nas" && !s.mounted &&
|
||||||
|
!confirm("NAS не смонтирован. Запись начнётся только после монтирования на хосте. Всё равно сделать активным?")) return;
|
||||||
|
const r = await fetch(`/api/storages/${id}/activate`, { method: "POST" });
|
||||||
|
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||||||
|
await this.load();
|
||||||
|
this.msg("Активное хранилище изменено — рекордеры перезапущены");
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id) {
|
||||||
|
if (!confirm(`Убрать хранилище «${id}» из списка?\nФайлы записей НЕ удаляются — архив сохранится.`)) return;
|
||||||
|
const r = await fetch(`/api/storages/${id}`, { method: "DELETE" });
|
||||||
|
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveQuota(id) {
|
||||||
|
const inp = document.querySelector(`.q-input[data-id="${CSS.escape(id)}"]`);
|
||||||
|
if (!inp) return;
|
||||||
|
const q = Math.max(0, parseInt(inp.value, 10) || 0);
|
||||||
|
const r = await fetch(`/api/storages/${id}`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ quota_gb: q }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||||||
|
this.msg(`Квота «${id}»: ${q ? q + " ГБ" : "без лимита"} ✓`);
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
const val = (id) => { const el = document.getElementById(id); return el ? el.value : ""; };
|
||||||
|
const type = val("sf-type");
|
||||||
|
const payload = {
|
||||||
|
id: val("sf-id").trim(), name: val("sf-name").trim(), type,
|
||||||
|
quota_gb: Math.max(0, parseInt(val("sf-quota"), 10) || 0),
|
||||||
|
};
|
||||||
|
if (type === "local") {
|
||||||
|
const p = val("sf-path").trim(); if (p) payload.path = p;
|
||||||
|
} else {
|
||||||
|
payload.server = val("sf-server").trim();
|
||||||
|
payload.share = val("sf-share").trim();
|
||||||
|
payload.username = val("sf-user").trim();
|
||||||
|
payload.password = val("sf-pass");
|
||||||
|
payload.subpath = val("sf-subpath").trim();
|
||||||
|
}
|
||||||
|
const err = document.getElementById("sf-err"); err.textContent = "";
|
||||||
|
const r = await fetch("/api/storages", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) { err.textContent = d.detail || ("Ошибка " + r.status); return; }
|
||||||
|
if (d.mount_help) {
|
||||||
|
document.getElementById("sf-mount").textContent =
|
||||||
|
"Хранилище создано. Смонтируйте SMB на ХОСТЕ (.79):\n\n" +
|
||||||
|
d.mount_help.mount_cmd + "\n\nАвтомонтаж — строка в /etc/fstab:\n" +
|
||||||
|
d.mount_help.fstab + "\n\n" + (d.mount_help.note || "");
|
||||||
|
this.msg("NAS-хранилище создано — смонтируйте том на хосте");
|
||||||
|
} else {
|
||||||
|
this.toggleForm(false);
|
||||||
|
}
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePolicy() {
|
||||||
|
const seg = +document.getElementById("pol-segment").value;
|
||||||
|
const ret = Math.max(0, parseInt(document.getElementById("pol-retention").value, 10) || 0);
|
||||||
|
const saved = document.getElementById("pol-saved"); saved.textContent = "Сохранение…";
|
||||||
|
const r = await fetch("/api/storages/policy", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ segment_seconds: seg, retention_days: ret }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) { saved.textContent = d.detail || ("Ошибка " + r.status); return; }
|
||||||
|
saved.textContent = "Сохранено ✓ (смена длины сегмента перезапускает рекордеры)";
|
||||||
|
clearTimeout(this._t); this._t = setTimeout(() => { saved.textContent = ""; }, 4000);
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
msg(t) {
|
||||||
|
const m = document.getElementById("stor-msg"); if (!m) return;
|
||||||
|
m.textContent = t; clearTimeout(this._m); this._m = setTimeout(() => { m.textContent = ""; }, 4000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// Страница РАСКЛАДКА (назначение камер по ячейкам)
|
// Страница РАСКЛАДКА (назначение камер по ячейкам)
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
@@ -1807,6 +1964,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
initTabs();
|
initTabs();
|
||||||
CamSettings.init();
|
CamSettings.init();
|
||||||
Settings.init();
|
Settings.init();
|
||||||
|
if (roleAtLeast("admin")) Storages.init();
|
||||||
LayoutEditor.init();
|
LayoutEditor.init();
|
||||||
if (roleAtLeast("superadmin")) Users.init();
|
if (roleAtLeast("superadmin")) Users.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,6 +441,10 @@ table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
|
|||||||
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
||||||
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
||||||
}
|
}
|
||||||
|
.badge-active { background: rgba(46, 204, 113, .18); color: var(--ok); font-weight: 600; }
|
||||||
|
.q-cell { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.q-cell .q-input { width: 78px; }
|
||||||
|
.q-cell button { padding: 2px 8px; font-size: 12px; }
|
||||||
.logbox {
|
.logbox {
|
||||||
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
||||||
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
|
|||||||
Reference in New Issue
Block a user