storage: per-camera target + date subfolders; move active-storage widget

- Camera.storage_id: each camera chooses its storage ("" = active/default).
  supervisor resolves per-camera (storage_by_id → active fallback); cameras
  API (body/payload) + /toggle accept storage_id and repoint the recorder.
- Recordings now go to <root>/<cam>/<YYYY-MM-DD>/<file>.mp4. This ffmpeg build
  lacks -strftime_mkdir, so recorder_task pre-creates today+tomorrow dirs at
  start and the indexer re-ensures them each tick (survives midnight).
- indexer: scan date subdirs + legacy flat files; sort by basename so the
  in-progress segment is detected correctly when both layouts coexist.
- frontend: per-camera "Хранилище" dropdown in the camera table (inline) and
  in the add/edit + bulk forms. Move the active-storage summary widget from
  the Система tab into the Хранилища tab.

VERSION 0.0.129.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:47:44 +05:00
parent a64749ccc8
commit 8e4844111b
9 changed files with 114 additions and 40 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.127 0.0.129
+7 -3
View File
@@ -39,6 +39,7 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
password=password, password=password,
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(), main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
sub_path=sub, sub_path=sub,
storage_id=(body.get("storage_id") or "").strip(),
) )
@@ -57,6 +58,7 @@ def _camera_payload(cam_id: str) -> dict:
"password": cam.password, "password": cam.password,
"main_path": cam.main_path, "main_path": cam.main_path,
"sub_path": cam.sub_path, "sub_path": cam.sub_path,
"storage_id": cam.storage_id,
"has_sub": has_sub, "has_sub": has_sub,
"status": status.to_dict() if status else None, "status": status.to_dict() if status else None,
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции) # live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
@@ -324,8 +326,10 @@ async def toggle_camera(cam_id: str, request: Request) -> dict:
kw["enabled"] = bool(body["enabled"]) kw["enabled"] = bool(body["enabled"])
if "record" in body: if "record" in body:
kw["record"] = bool(body["record"]) kw["record"] = bool(body["record"])
if "storage_id" in body: # смена хранилища записи камеры
kw["storage_id"] = (body["storage_id"] or "").strip()
if not kw: if not kw:
raise HTTPException(400, "ожидается enabled и/или record") raise HTTPException(400, "ожидается enabled, record и/или storage_id")
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
new = replace(cam, **kw) new = replace(cam, **kw)
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id) idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
@@ -336,5 +340,5 @@ async def toggle_camera(cam_id: str, request: Request) -> dict:
await go2rtc.add_camera_streams(runtime.config, new) await go2rtc.add_camera_streams(runtime.config, new)
else: else:
await go2rtc.remove_camera_streams(runtime.config, cam_id) await go2rtc.remove_camera_streams(runtime.config, cam_id)
await runtime.supervisor.replace_camera(new) # рекордер запускается при enabled AND record await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
return {"ok": True, "enabled": new.enabled, "record": new.record} return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
+3
View File
@@ -19,6 +19,7 @@ class Camera:
main_path: str main_path: str
sub_path: str | None = None sub_path: str | None = None
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись) record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
def rtsp_url(self, path: str) -> str: def rtsp_url(self, path: str) -> str:
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}" return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
@@ -141,6 +142,7 @@ def _camera_from_raw(c: dict) -> Camera:
password=c["password"], password=c["password"],
main_path=c["main_path"], main_path=c["main_path"],
sub_path=c.get("sub_path"), sub_path=c.get("sub_path"),
storage_id=c.get("storage_id", "") or "",
) )
@@ -198,6 +200,7 @@ def camera_to_yaml(c: Camera) -> dict:
"password": c.password, "password": c.password,
"main_path": c.main_path, "main_path": c.main_path,
"sub_path": c.sub_path, "sub_path": c.sub_path,
"storage_id": c.storage_id,
} }
+4 -4
View File
@@ -16,10 +16,10 @@ def record_args(cam: Camera, out_root: str, segment_seconds: int,
out_root — корень активного хранилища (внутри пишем подкаталог камеры). out_root — корень активного хранилища (внутри пишем подкаталог камеры).
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию). input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
""" """
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз # Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь. # Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir. # заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d_%H-%M-%S.mp4") out_template = os.path.join(out_root, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
return [ return [
"ffmpeg", "ffmpeg",
"-nostdin", "-nostdin",
+37 -8
View File
@@ -10,7 +10,7 @@ import asyncio
import logging import logging
import os import os
import time import time
from datetime import datetime from datetime import datetime, timedelta
from ..config import Config from ..config import Config
from ..db import Database from ..db import Database
@@ -32,7 +32,11 @@ def _parse_started_at(path: str) -> int:
def _scan_segments(root: str) -> dict[str, list[str]]: def _scan_segments(root: str) -> dict[str, list[str]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}.""" """Возвращает {camera_id: [пути mp4, отсортированы]}.
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
result: dict[str, list[str]] = {} result: dict[str, list[str]] = {}
if not os.path.isdir(root): if not os.path.isdir(root):
return result return result
@@ -40,12 +44,19 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
cam_dir = os.path.join(root, cam_id) cam_dir = os.path.join(root, cam_id)
if not os.path.isdir(cam_dir): if not os.path.isdir(cam_dir):
continue continue
files = [ files: list[str] = []
os.path.join(cam_dir, fn) for entry in os.listdir(cam_dir):
for fn in os.listdir(cam_dir) p = os.path.join(cam_dir, entry)
if fn.endswith(".mp4") if entry.endswith(".mp4") and os.path.isfile(p):
] files.append(p) # старый плоский формат
files.sort() elif os.path.isdir(p): # подпапка даты
for fn in os.listdir(p):
if fn.endswith(".mp4"):
files.append(os.path.join(p, fn))
# сортируем по ИМЕНИ файла (YYYY-MM-DD_HH-MM-SS) — хронологически независимо от того,
# лежит файл плоско или в подпапке даты (полный путь сортировал бы '/' < '_' неверно,
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
files.sort(key=os.path.basename)
if files: if files:
result[cam_id] = files result[cam_id] = files
return result return result
@@ -74,11 +85,29 @@ class Indexer:
async def _loop(self) -> None: async def _loop(self) -> None:
while not self._stopping: while not self._stopping:
try: try:
self._ensure_date_dirs()
await self.scan_once() await self.scan_once()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.exception("indexer scan failed") log.exception("indexer scan failed")
await asyncio.sleep(SCAN_INTERVAL) await asyncio.sleep(SCAN_INTERVAL)
def _ensure_date_dirs(self) -> None:
"""Создаёт каталоги даты (сегодня+завтра) для пишущих камер заранее: ffmpeg
не умеет -strftime_mkdir, без готового каталога запись встала бы в полночь."""
today = datetime.now()
days = [today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")]
for cam in self.config.cameras:
if not (cam.enabled and cam.record):
continue
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
if store.type == "nas" and not os.path.ismount(store.path):
continue
for d in days:
try:
os.makedirs(os.path.join(store.path, cam.id, d), exist_ok=True)
except OSError:
pass
async def scan_once(self) -> int: async def scan_once(self) -> int:
active_path = self.config.active_storage().path active_path = self.config.active_storage().path
known = await self.db.known_paths() known = await self.db.known_paths()
+7 -1
View File
@@ -6,6 +6,7 @@ import logging
import os import os
import time import time
from collections import deque from collections import deque
from datetime import datetime, timedelta
from ..config import Camera, StorageItem from ..config import Camera, StorageItem
from ..models import CameraState, CameraStatus from ..models import CameraState, CameraStatus
@@ -83,7 +84,12 @@ class RecorderTask:
continue continue
try: try:
os.makedirs(os.path.join(self.store.path, self.cam.id), exist_ok=True) # подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
base = os.path.join(self.store.path, self.cam.id)
now = datetime.now()
for d in (now, now + timedelta(days=1)):
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
except OSError as exc: except OSError as exc:
self._set_state(CameraState.RETRYING, f"mkdir: {exc}") self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
backoff = min(backoff * 2, BACKOFF_MAX) backoff = min(backoff * 2, BACKOFF_MAX)
+4 -3
View File
@@ -22,9 +22,10 @@ class Supervisor:
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: def _new_task(self, cam: Camera) -> RecorderTask:
# пишем в активное хранилище; смена активного → restart_all репойнтит все задачи # хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
active = self.config.active_storage() # id (удалённое хранилище) → тоже откат на активное.
return RecorderTask(cam, active, self.config.storage.segment_seconds, store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
return RecorderTask(cam, store, self.config.storage.segment_seconds,
on_status=self.on_status, input_url=self._input_url(cam)) on_status=self.on_status, input_url=self._input_url(cam))
def start_all(self) -> None: def start_all(self) -> None:
+6 -6
View File
@@ -56,7 +56,7 @@
</div> </div>
<table class="cams"> <table class="cams">
<thead> <thead>
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th>Статус</th><th></th></tr> <tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th style="width:150px">Хранилище</th><th>Статус</th><th></th></tr>
</thead> </thead>
<tbody id="cam-rows"></tbody> <tbody id="cam-rows"></tbody>
</table> </table>
@@ -140,16 +140,16 @@
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></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 class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
</div> </div>
<div class="card">
<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>
</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">
<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>
<div class="card"> <div class="card">
<h2>Система</h2> <h2>Система</h2>
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div> <div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
+45 -14
View File
@@ -1123,6 +1123,7 @@ const CamSettings = {
bulk: false, // режим массового редактирования всей таблицы bulk: false, // режим массового редактирования всей таблицы
cams: [], cams: [],
byId: {}, byId: {},
storages: [], // список хранилищ для выпадающего выбора в строке камеры
async init() { async init() {
const add = document.getElementById("add-cam"); const add = document.getElementById("add-cam");
@@ -1133,10 +1134,24 @@ const CamSettings = {
if (bc) bc.onclick = () => this.exitBulk(); if (bc) bc.onclick = () => this.exitBulk();
const cc = document.getElementById("clear-cams"); const cc = document.getElementById("clear-cams");
if (cc) cc.onclick = () => this.clearAll(); if (cc) cc.onclick = () => this.clearAll();
try { this.storages = (await api("/api/storages")).storages || []; } catch (e) { this.storages = []; }
await this.load(); await this.load();
connectStatusWS(() => { if (!this.editing) this.load(); }); connectStatusWS(() => { if (!this.editing) this.load(); });
}, },
// выпадающий список хранилищ; "" = по умолчанию (активное)
storageOptions(sel) {
const v = (x) => this.esc(x);
let html = `<option value=""${!sel ? " selected" : ""}>По умолчанию</option>`;
let found = false;
(this.storages || []).forEach((s) => {
const isSel = s.id === sel; if (isSel) found = true;
html += `<option value="${v(s.id)}"${isSel ? " selected" : ""}>${v(s.name)}</option>`;
});
if (sel && !found) html += `<option value="${v(sel)}" selected>${v(sel)} (нет)</option>`;
return html;
},
setBulkButtons() { setBulkButtons() {
const be = document.getElementById("bulk-edit"); const be = document.getElementById("bulk-edit");
const bc = document.getElementById("bulk-cancel"); const bc = document.getElementById("bulk-cancel");
@@ -1183,7 +1198,7 @@ const CamSettings = {
bulkRowHtml(slotNo, cam) { bulkRowHtml(slotNo, cam) {
const v = (x) => this.esc(x); const v = (x) => this.esc(x);
return ( return (
`<td colspan="8" class="cam-edit">` + `<td colspan="9" class="cam-edit">` +
`<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` + `<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` +
`<div class="cam-edit-grid" data-cam="${v(cam.id)}">` + `<div class="cam-edit-grid" data-cam="${v(cam.id)}">` +
`<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` + `<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` +
@@ -1192,6 +1207,7 @@ const CamSettings = {
`<label>Пароль<input type="password" data-f="password" value="${v(cam.password)}"></label>` + `<label>Пароль<input type="password" data-f="password" value="${v(cam.password)}"></label>` +
`<label>Осн. поток<input type="text" data-f="main_path" value="${v(cam.main_path)}"></label>` + `<label>Осн. поток<input type="text" data-f="main_path" value="${v(cam.main_path)}"></label>` +
`<label>Субпоток<input type="text" data-f="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` + `<label>Субпоток<input type="text" data-f="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` +
`<label>Хранилище<select data-f="storage_id">${this.storageOptions(cam.storage_id)}</select></label>` +
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` + `<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` + `<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` +
`</div>` + `</div>` +
@@ -1213,6 +1229,7 @@ const CamSettings = {
password: get("password"), password: get("password"),
main_path: get("main_path").trim(), main_path: get("main_path").trim(),
sub_path: get("sub_path").trim(), sub_path: get("sub_path").trim(),
storage_id: get("storage_id"),
enabled: g.querySelector('[data-f="enabled"]').checked, enabled: g.querySelector('[data-f="enabled"]').checked,
record: g.querySelector('[data-f="record"]').checked, record: g.querySelector('[data-f="record"]').checked,
}; };
@@ -1221,6 +1238,7 @@ const CamSettings = {
payload.user !== orig.user || payload.password !== (orig.password || "") || payload.user !== orig.user || payload.password !== (orig.password || "") ||
payload.main_path !== orig.main_path || payload.main_path !== orig.main_path ||
(payload.sub_path || "") !== (orig.sub_path || "") || (payload.sub_path || "") !== (orig.sub_path || "") ||
(payload.storage_id || "") !== (orig.storage_id || "") ||
payload.enabled !== orig.enabled || payload.enabled !== orig.enabled ||
payload.record !== orig.record; payload.record !== orig.record;
if (!changed) continue; if (!changed) continue;
@@ -1243,7 +1261,7 @@ const CamSettings = {
const v = (x) => this.esc(x); const v = (x) => this.esc(x);
const isEdit = !!cam; const isEdit = !!cam;
return ( return (
`<td colspan="8" class="cam-edit">` + `<td colspan="9" class="cam-edit">` +
`<div class="cam-edit-grid">` + `<div class="cam-edit-grid">` +
`<label>ID<input type="text" id="e-id" value="${cam ? v(cam.id) : ""}" ${isEdit ? "disabled" : ""} placeholder="cam${slotNo}"></label>` + `<label>ID<input type="text" id="e-id" value="${cam ? v(cam.id) : ""}" ${isEdit ? "disabled" : ""} placeholder="cam${slotNo}"></label>` +
`<label>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` + `<label>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` +
@@ -1252,6 +1270,7 @@ const CamSettings = {
`<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` + `<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` +
`<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` + `<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` +
`<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` + `<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
`<label>Хранилище<select id="e-storage">${this.storageOptions(cam ? cam.storage_id : "")}</select></label>` +
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` + `<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` + `<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
`</div>` + `</div>` +
@@ -1295,6 +1314,7 @@ const CamSettings = {
password: val("e-pass"), password: val("e-pass"),
main_path: val("e-main").trim(), main_path: val("e-main").trim(),
sub_path: val("e-sub").trim(), sub_path: val("e-sub").trim(),
storage_id: val("e-storage"),
enabled: document.getElementById("e-enabled").checked, enabled: document.getElementById("e-enabled").checked,
record: document.getElementById("e-record").checked, record: document.getElementById("e-record").checked,
}; };
@@ -1359,6 +1379,7 @@ const CamSettings = {
`<td class="muted">${streams}</td>` + `<td class="muted">${streams}</td>` +
`<td><input type="checkbox" class="en-toggle" data-id="${c.id}" title="Камера включена (выкл — не работает совсем)" ${c.enabled ? "checked" : ""}></td>` + `<td><input type="checkbox" class="en-toggle" data-id="${c.id}" title="Камера включена (выкл — не работает совсем)" ${c.enabled ? "checked" : ""}></td>` +
`<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></td>` + `<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></td>` +
`<td><select class="stor-sel" data-id="${c.id}" title="В какое хранилище писать">${this.storageOptions(c.storage_id)}</select></td>` +
`<td>${statusCell}</td>` + `<td>${statusCell}</td>` +
`<td class="actions">` + `<td class="actions">` +
`<button data-act="log" data-id="${c.id}">Лог</button> ` + `<button data-act="log" data-id="${c.id}">Лог</button> ` +
@@ -1369,14 +1390,14 @@ const CamSettings = {
const logTr = document.createElement("tr"); const logTr = document.createElement("tr");
logTr.className = "logrow"; logTr.className = "logrow";
const hid = openLogs.has("log-" + c.id) ? "" : "hidden"; const hid = openLogs.has("log-" + c.id) ? "" : "hidden";
logTr.innerHTML = `<td colspan="8" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`; logTr.innerHTML = `<td colspan="9" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
tb.appendChild(logTr); tb.appendChild(logTr);
} else { } else {
tr.className = "slot-empty"; tr.className = "slot-empty";
tr.dataset.slot = i + 1; tr.dataset.slot = i + 1;
tr.innerHTML = tr.innerHTML =
`<td class="muted">${i + 1}</td>` + `<td class="muted">${i + 1}</td>` +
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` + `<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
`<td class="muted">пусто</td>` + `<td class="muted">пусто</td>` +
`<td class="actions"><button data-act="add">+ Добавить</button></td>`; `<td class="actions"><button data-act="add">+ Добавить</button></td>`;
tb.appendChild(tr); tb.appendChild(tr);
@@ -1411,23 +1432,33 @@ const CamSettings = {
} }
}; };
tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись) tb.onchange = async (e) => { // в строке: «Вкл», «REC» (тумблеры) и «Хранилище» (select)
const en = e.target.closest(".en-toggle"); const en = e.target.closest(".en-toggle");
const cb = en || e.target.closest(".rec-toggle"); const rec = e.target.closest(".rec-toggle");
if (!cb) return; const stor = e.target.closest(".stor-sel");
const id = cb.dataset.id; const ctrl = en || rec || stor;
const patch = en ? { enabled: cb.checked } : { record: cb.checked }; if (!ctrl) return;
cb.disabled = true; const id = ctrl.dataset.id;
let patch, revert;
if (stor) {
patch = { storage_id: stor.value };
const prev = (this.byId[id] && this.byId[id].storage_id) || "";
revert = () => { stor.value = prev; };
} else {
patch = en ? { enabled: ctrl.checked } : { record: ctrl.checked };
revert = () => { ctrl.checked = !ctrl.checked; };
}
ctrl.disabled = true;
try { try {
const r = await fetch(`/api/cameras/${id}/toggle`, { const r = await fetch(`/api/cameras/${id}/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; } if (!r.ok) { revert(); const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
if (this.byId[id]) Object.assign(this.byId[id], patch); if (this.byId[id]) Object.assign(this.byId[id], patch);
await this.load(); // перерисовать: REC зависит от состояния «Вкл» await this.load(); // перерисовать (REC зависит от «Вкл», строка хранилища)
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); } } catch (err) { revert(); alert("Сбой запроса"); }
finally { cb.disabled = false; } finally { ctrl.disabled = false; }
}; };
}, },
}; };