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
+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
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 ..services.system import system_stats
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")
@@ -36,25 +33,3 @@ async def get_system() -> dict:
@router.get("/health")
async def health() -> dict:
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}