cameras: split camera enable (Вкл) from REC (record-to-disk)
Add a separate `record` field so a camera can be registered but fully
off, or on but not recording:
- enabled (Вкл): camera on/off. Disabled = no go2rtc stream (no live),
no recording; entry is just stored. build_streams/sync_all/reconcile
skip disabled cameras; live grid shows "камера выключена".
- record (REC): write segments to disk. Recorder runs only when
enabled AND record.
Unified POST /api/cameras/{id}/toggle {enabled?, record?} (replaces the
/record endpoint); go2rtc is touched only when `enabled` changes, so
flipping REC doesn't blip live. Camera table gets Вкл + Запись toggle
columns; add/edit forms get both switches.
load_config migrates the old schema (no `record` key): old `enabled`
meant "record" with live always on -> enabled=True, record=old enabled.
VERSION -> 0.0.126.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-11
@@ -33,6 +33,7 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
|||||||
id=cam_id,
|
id=cam_id,
|
||||||
name=(body.get("name") or cam_id).strip(),
|
name=(body.get("name") or cam_id).strip(),
|
||||||
enabled=bool(body.get("enabled", True)),
|
enabled=bool(body.get("enabled", True)),
|
||||||
|
record=bool(body.get("record", True)),
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
@@ -50,6 +51,7 @@ def _camera_payload(cam_id: str) -> dict:
|
|||||||
"id": cam.id,
|
"id": cam.id,
|
||||||
"name": cam.name,
|
"name": cam.name,
|
||||||
"enabled": cam.enabled,
|
"enabled": cam.enabled,
|
||||||
|
"record": cam.record,
|
||||||
"ip": cam.ip,
|
"ip": cam.ip,
|
||||||
"user": cam.user,
|
"user": cam.user,
|
||||||
"password": cam.password,
|
"password": cam.password,
|
||||||
@@ -255,7 +257,8 @@ async def create_camera(request: Request) -> dict:
|
|||||||
runtime.config.cameras.append(cam)
|
runtime.config.cameras.append(cam)
|
||||||
save_config(runtime.config)
|
save_config(runtime.config)
|
||||||
runtime.supervisor.add_camera(cam)
|
runtime.supervisor.add_camera(cam)
|
||||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
if cam.enabled:
|
||||||
|
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||||
return _camera_payload(cam_id)
|
return _camera_payload(cam_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -270,7 +273,10 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
|||||||
runtime.config.cameras[idx] = cam
|
runtime.config.cameras[idx] = cam
|
||||||
save_config(runtime.config)
|
save_config(runtime.config)
|
||||||
await runtime.supervisor.replace_camera(cam)
|
await runtime.supervisor.replace_camera(cam)
|
||||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
if cam.enabled:
|
||||||
|
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||||
|
else:
|
||||||
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
return _camera_payload(cam_id)
|
return _camera_payload(cam_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -301,21 +307,34 @@ async def clear_cameras(request: Request) -> dict:
|
|||||||
return {"ok": True, "removed": len(ids)}
|
return {"ok": True, "removed": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{cam_id}/record")
|
@router.post("/{cam_id}/toggle")
|
||||||
async def set_record(cam_id: str, request: Request) -> dict:
|
async def toggle_camera(cam_id: str, request: Request) -> dict:
|
||||||
"""Включает/выключает запись камеры на диск (тумблер REC).
|
"""Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск).
|
||||||
|
|
||||||
enabled=False — запись не идёт, но go2rtc/live не трогаем → просмотр работает.
|
enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи
|
||||||
Меняет только рекордер (без пересоздания go2rtc-потоков, чтобы live не мигал)."""
|
(камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем
|
||||||
|
лишь при смене enabled, поэтому переключение REC не мигает live."""
|
||||||
require_role(request, "admin")
|
require_role(request, "admin")
|
||||||
cam = runtime.config.camera(cam_id)
|
cam = runtime.config.camera(cam_id)
|
||||||
if not cam:
|
if not cam:
|
||||||
raise HTTPException(404, "camera not found")
|
raise HTTPException(404, "camera not found")
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
on = bool(body.get("enabled", True))
|
kw = {}
|
||||||
|
if "enabled" in body:
|
||||||
|
kw["enabled"] = bool(body["enabled"])
|
||||||
|
if "record" in body:
|
||||||
|
kw["record"] = bool(body["record"])
|
||||||
|
if not kw:
|
||||||
|
raise HTTPException(400, "ожидается enabled и/или record")
|
||||||
|
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
|
||||||
|
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)
|
||||||
new = replace(cam, enabled=on)
|
|
||||||
runtime.config.cameras[idx] = new
|
runtime.config.cameras[idx] = new
|
||||||
save_config(runtime.config)
|
save_config(runtime.config)
|
||||||
await runtime.supervisor.replace_camera(new) # старт/стоп записи; go2rtc не трогаем
|
if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры
|
||||||
return {"ok": True, "enabled": on}
|
if new.enabled:
|
||||||
|
await go2rtc.add_camera_streams(runtime.config, new)
|
||||||
|
else:
|
||||||
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
|
await runtime.supervisor.replace_camera(new) # рекордер запускается при enabled AND record
|
||||||
|
return {"ok": True, "enabled": new.enabled, "record": new.record}
|
||||||
|
|||||||
+24
-13
@@ -18,6 +18,7 @@ class Camera:
|
|||||||
password: str
|
password: str
|
||||||
main_path: str
|
main_path: str
|
||||||
sub_path: str | None = None
|
sub_path: str | None = None
|
||||||
|
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
||||||
|
|
||||||
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}"
|
||||||
@@ -70,27 +71,37 @@ def load_config(path: str | None = None) -> Config:
|
|||||||
|
|
||||||
storage = Storage(**(raw.get("storage") or {}))
|
storage = Storage(**(raw.get("storage") or {}))
|
||||||
live = Live(**(raw.get("live") or {}))
|
live = Live(**(raw.get("live") or {}))
|
||||||
cameras = [
|
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
||||||
Camera(
|
|
||||||
id=c["id"],
|
|
||||||
name=c.get("name", c["id"]),
|
|
||||||
enabled=c.get("enabled", True),
|
|
||||||
ip=c["ip"],
|
|
||||||
user=c["user"],
|
|
||||||
password=c["password"],
|
|
||||||
main_path=c["main_path"],
|
|
||||||
sub_path=c.get("sub_path"),
|
|
||||||
)
|
|
||||||
for c in (raw.get("cameras") or [])
|
|
||||||
]
|
|
||||||
return Config(storage=storage, live=live, cameras=cameras)
|
return Config(storage=storage, live=live, cameras=cameras)
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_from_raw(c: dict) -> Camera:
|
||||||
|
# Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда),
|
||||||
|
# отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем
|
||||||
|
# так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть.
|
||||||
|
if "record" in c:
|
||||||
|
enabled, record = bool(c.get("enabled", True)), bool(c["record"])
|
||||||
|
else:
|
||||||
|
enabled, record = True, bool(c.get("enabled", True))
|
||||||
|
return Camera(
|
||||||
|
id=c["id"],
|
||||||
|
name=c.get("name", c["id"]),
|
||||||
|
enabled=enabled,
|
||||||
|
record=record,
|
||||||
|
ip=c["ip"],
|
||||||
|
user=c["user"],
|
||||||
|
password=c["password"],
|
||||||
|
main_path=c["main_path"],
|
||||||
|
sub_path=c.get("sub_path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def camera_to_yaml(c: Camera) -> dict:
|
def camera_to_yaml(c: Camera) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
"enabled": c.enabled,
|
"enabled": c.enabled,
|
||||||
|
"record": c.record,
|
||||||
"ip": c.ip,
|
"ip": c.ip,
|
||||||
"user": c.user,
|
"user": c.user,
|
||||||
"password": c.password,
|
"password": c.password,
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ class Supervisor:
|
|||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||||
input_url=self._input_url(cam))
|
input_url=self._input_url(cam))
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
log.info("recorder started: %s", cam.id)
|
log.info("recorder started: %s", cam.id)
|
||||||
else:
|
else:
|
||||||
log.info("recorder skipped (disabled): %s", cam.id)
|
log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id)
|
||||||
|
|
||||||
async def stop_all(self) -> None:
|
async def stop_all(self) -> None:
|
||||||
for task in self.tasks.values():
|
for task in self.tasks.values():
|
||||||
@@ -60,7 +60,7 @@ class Supervisor:
|
|||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||||
input_url=self._input_url(cam))
|
input_url=self._input_url(cam))
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
log.info("recorder added: %s", cam.id)
|
log.info("recorder added: %s", cam.id)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]:
|
|||||||
def build_streams(config: Config) -> dict:
|
def build_streams(config: Config) -> dict:
|
||||||
streams: dict = {}
|
streams: dict = {}
|
||||||
for c in config.cameras:
|
for c in config.cameras:
|
||||||
|
if not c.enabled: # выключенная камера — без потоков (ни live, ни записи)
|
||||||
|
continue
|
||||||
for name, src in _streams_for(c).items():
|
for name, src in _streams_for(c).items():
|
||||||
streams[name] = [src]
|
streams[name] = [src]
|
||||||
return streams
|
return streams
|
||||||
@@ -120,6 +122,8 @@ async def sync_all(config: Config) -> None:
|
|||||||
write_go2rtc_yaml(config)
|
write_go2rtc_yaml(config)
|
||||||
base = config.live.go2rtc_url.rstrip("/")
|
base = config.live.go2rtc_url.rstrip("/")
|
||||||
for cam in config.cameras:
|
for cam in config.cameras:
|
||||||
|
if not cam.enabled:
|
||||||
|
continue
|
||||||
for name, src in _streams_for(cam).items():
|
for name, src in _streams_for(cam).items():
|
||||||
await _call("PUT", _put(base, name, src))
|
await _call("PUT", _put(base, name, src))
|
||||||
|
|
||||||
@@ -151,6 +155,8 @@ async def reconcile(config: Config) -> int:
|
|||||||
return 0
|
return 0
|
||||||
added = 0
|
added = 0
|
||||||
for cam in config.cameras:
|
for cam in config.cameras:
|
||||||
|
if not cam.enabled: # выключенные камеры в go2rtc не держим
|
||||||
|
continue
|
||||||
for name, src in _streams_for(cam).items():
|
for name, src in _streams_for(cam).items():
|
||||||
if name not in existing:
|
if name not in existing:
|
||||||
await _call("PUT", _put(base, name, src))
|
await _call("PUT", _put(base, name, src))
|
||||||
|
|||||||
@@ -55,7 +55,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:72px">Запись</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>Статус</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="cam-rows"></tbody>
|
<tbody id="cam-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+32
-15
@@ -632,6 +632,11 @@ const Live = {
|
|||||||
setTileMedia(tile, cam, useMain) {
|
setTileMedia(tile, cam, useMain) {
|
||||||
this.destroyTilePlayers(tile);
|
this.destroyTilePlayers(tile);
|
||||||
tile.querySelectorAll("video, video-stream, .hevc-player, .empty, .hevc-hint").forEach((e) => e.remove());
|
tile.querySelectorAll("video, video-stream, .hevc-player, .empty, .hevc-hint").forEach((e) => e.remove());
|
||||||
|
if (cam && !cam.enabled) { // камера выключена — ни записи, ни live
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = "empty"; d.textContent = "камера выключена";
|
||||||
|
tile.insertBefore(d, tile.firstChild); return;
|
||||||
|
}
|
||||||
if (!this.playing) {
|
if (!this.playing) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.className = "empty"; d.textContent = "⏸ просмотр остановлен";
|
d.className = "empty"; d.textContent = "⏸ просмотр остановлен";
|
||||||
@@ -1178,7 +1183,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="7" class="cam-edit">` +
|
`<td colspan="8" 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>` +
|
||||||
@@ -1187,7 +1192,8 @@ 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 class="sw" title="Запись на диск (выкл — только просмотр)">REC<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>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</td>`
|
`</td>`
|
||||||
);
|
);
|
||||||
@@ -1208,13 +1214,15 @@ const CamSettings = {
|
|||||||
main_path: get("main_path").trim(),
|
main_path: get("main_path").trim(),
|
||||||
sub_path: get("sub_path").trim(),
|
sub_path: get("sub_path").trim(),
|
||||||
enabled: g.querySelector('[data-f="enabled"]').checked,
|
enabled: g.querySelector('[data-f="enabled"]').checked,
|
||||||
|
record: g.querySelector('[data-f="record"]').checked,
|
||||||
};
|
};
|
||||||
const changed =
|
const changed =
|
||||||
payload.name !== orig.name || payload.ip !== orig.ip ||
|
payload.name !== orig.name || payload.ip !== orig.ip ||
|
||||||
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.enabled !== orig.enabled;
|
payload.enabled !== orig.enabled ||
|
||||||
|
payload.record !== orig.record;
|
||||||
if (!changed) continue;
|
if (!changed) continue;
|
||||||
const r = await fetch(`/api/cameras/${id}`, {
|
const r = await fetch(`/api/cameras/${id}`, {
|
||||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
@@ -1235,7 +1243,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="7" class="cam-edit">` +
|
`<td colspan="8" 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>` +
|
||||||
@@ -1244,7 +1252,8 @@ 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 class="sw" title="Запись на диск (выкл — только просмотр)">REC<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>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="cam-edit-foot">` +
|
`<div class="cam-edit-foot">` +
|
||||||
`<span class="err" id="e-err"></span>` +
|
`<span class="err" id="e-err"></span>` +
|
||||||
@@ -1287,6 +1296,7 @@ const CamSettings = {
|
|||||||
main_path: val("e-main").trim(),
|
main_path: val("e-main").trim(),
|
||||||
sub_path: val("e-sub").trim(),
|
sub_path: val("e-sub").trim(),
|
||||||
enabled: document.getElementById("e-enabled").checked,
|
enabled: document.getElementById("e-enabled").checked,
|
||||||
|
record: document.getElementById("e-record").checked,
|
||||||
};
|
};
|
||||||
const url = this.editId ? `/api/cameras/${this.editId}` : "/api/cameras";
|
const url = this.editId ? `/api/cameras/${this.editId}` : "/api/cameras";
|
||||||
const method = this.editId ? "PUT" : "POST";
|
const method = this.editId ? "PUT" : "POST";
|
||||||
@@ -1338,14 +1348,18 @@ const CamSettings = {
|
|||||||
if (c) {
|
if (c) {
|
||||||
const st = (c.status && c.status.state) || "offline";
|
const st = (c.status && c.status.state) || "offline";
|
||||||
const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : "");
|
const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : "");
|
||||||
|
const statusCell = c.enabled
|
||||||
|
? `<span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span>`
|
||||||
|
: `<span class="muted">выключена</span>`;
|
||||||
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
`<td class="muted">${i + 1}</td>` +
|
`<td class="muted">${i + 1}</td>` +
|
||||||
`<td>${c.name}</td>` +
|
`<td>${c.name}</td>` +
|
||||||
`<td class="muted">${c.ip}</td>` +
|
`<td class="muted">${c.ip}</td>` +
|
||||||
`<td class="muted">${streams}</td>` +
|
`<td class="muted">${streams}</td>` +
|
||||||
`<td><input type="checkbox" class="rec-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><span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span></td>` +
|
`<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></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> ` +
|
||||||
`<button data-act="restart" data-id="${c.id}">⟳</button> ` +
|
`<button data-act="restart" data-id="${c.id}">⟳</button> ` +
|
||||||
@@ -1355,14 +1369,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="7" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
|
logTr.innerHTML = `<td colspan="8" 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="actions"><button data-act="add">+ Добавить</button></td>`;
|
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
@@ -1397,18 +1411,21 @@ const CamSettings = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tb.onchange = async (e) => { // тумблер REC в строке — старт/стоп записи на диск
|
tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись)
|
||||||
const cb = e.target.closest(".rec-toggle");
|
const en = e.target.closest(".en-toggle");
|
||||||
|
const cb = en || e.target.closest(".rec-toggle");
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
const id = cb.dataset.id;
|
const id = cb.dataset.id;
|
||||||
|
const patch = en ? { enabled: cb.checked } : { record: cb.checked };
|
||||||
cb.disabled = true;
|
cb.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/cameras/${id}/record`, {
|
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({ enabled: cb.checked }),
|
body: JSON.stringify(patch),
|
||||||
});
|
});
|
||||||
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); }
|
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||||||
else if (this.byId[id]) { this.byId[id].enabled = cb.checked; }
|
if (this.byId[id]) Object.assign(this.byId[id], patch);
|
||||||
|
await this.load(); // перерисовать: REC зависит от состояния «Вкл»
|
||||||
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
|
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
|
||||||
finally { cb.disabled = false; }
|
finally { cb.disabled = false; }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user