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:
2026-06-01 19:31:15 +05:00
parent 24155a8492
commit a64749ccc8
16 changed files with 746 additions and 126 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.126 0.0.127
+235
View File
@@ -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}
+1 -26
View File
@@ -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
View File
@@ -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],
+26
View File
@@ -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
View File
@@ -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)
+6 -4
View File
@@ -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",
+8 -4
View File
@@ -80,14 +80,18 @@ 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 root in self.config.storage_paths():
by_cam = _scan_segments(root)
is_active = (root == active_path)
for cam_id, files in by_cam.items(): for cam_id, files in by_cam.items():
# последний файл может писаться прямо сейчас — не индексируем # в активном хранилище последний файл ещё пишется — пропускаем;
complete = files[:-1] if len(files) > 1 else [] # в неактивных все сегменты завершены, индексируем целиком.
complete = files[:-1] if is_active else files
for path in complete: for path in complete:
if path in known: if path in known:
continue continue
+23 -5
View File
@@ -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(
+8 -4
View File
@@ -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()
+9 -7
View File
@@ -61,19 +61,21 @@ 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
quota = item.quota_gb * 1024 ** 3
while await self.db.total_size_under(item.path) > quota:
batch = await self.db.oldest_recordings_under(item.path, limit=20)
if not batch: if not batch:
break break
for rec in batch: for rec in batch:
await self._remove(rec.id, rec.path) await self._remove(rec.id, rec.path)
removed += 1 removed += 1
if await self.db.total_size() <= quota: if await self.db.total_size_under(item.path) <= quota:
break break
return removed return removed
+19 -6
View File
@@ -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
View File
@@ -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
+76 -10
View File
@@ -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>
<!-- ВКЛАДКА 2: Система --> <!-- ВКЛАДКА: Хранилища (управление записью) -->
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden> <div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
<div class="wrap"> <div class="card">
<div class="card" data-min-role="admin"> <div class="card-head">
<h2>Запись</h2> <h2>Хранилища записи</h2>
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label> <button id="stor-add">+ Добавить хранилище</button>
<select id="segment-size" style="margin-top:6px;display:block"> </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="60">1 минута</option>
<option value="120">2 минуты</option> <option value="120">2 минуты</option>
<option value="300">5 минут</option> <option value="300">5 минут</option>
<option value="600">10 минут</option> <option value="600">10 минут</option>
<option value="900">15 минут</option> <option value="900">15 минут</option>
</select> </select>
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div> </label>
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div> <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> </div>
<!-- ВКЛАДКА 2: Система -->
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
<div class="wrap">
<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
View File
@@ -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, "&quot;"); },
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();
} }
+4
View File
@@ -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;