cameras: surface per-camera REC (record-to-disk) toggle

`enabled` already gated recording only (go2rtc/live work regardless), but
it was a buried "Вкл" checkbox. Make it an explicit REC control:
- New "Запись" column with an inline REC switch in each camera row.
- Relabel the add/edit form toggle "Вкл" -> "REC" + tooltip.
- New POST /api/cameras/{id}/record {enabled} that flips recording through
  the supervisor only (no go2rtc re-sync, so live view doesn't blip).

Off = view-only (live via go2rtc); on = record segments to disk.
VERSION -> 0.0.124.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 18:21:49 +05:00
parent 81a5b58eb9
commit 7060f26e86
4 changed files with 46 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.123 0.0.124
+21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import re import re
from dataclasses import replace
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -298,3 +299,23 @@ async def clear_cameras(request: Request) -> dict:
await runtime.supervisor.remove_camera(cam_id) await runtime.supervisor.remove_camera(cam_id)
await go2rtc.remove_camera_streams(runtime.config, cam_id) await go2rtc.remove_camera_streams(runtime.config, cam_id)
return {"ok": True, "removed": len(ids)} return {"ok": True, "removed": len(ids)}
@router.post("/{cam_id}/record")
async def set_record(cam_id: str, request: Request) -> dict:
"""Включает/выключает запись камеры на диск (тумблер REC).
enabled=False — запись не идёт, но go2rtc/live не трогаем → просмотр работает.
Меняет только рекордер (без пересоздания go2rtc-потоков, чтобы live не мигал)."""
require_role(request, "admin")
cam = runtime.config.camera(cam_id)
if not cam:
raise HTTPException(404, "camera not found")
body = await request.json()
on = bool(body.get("enabled", True))
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
save_config(runtime.config)
await runtime.supervisor.replace_camera(new) # старт/стоп записи; go2rtc не трогаем
return {"ok": True, "enabled": on}
+1 -1
View File
@@ -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>Статус</th><th></th></tr> <tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:72px">Запись</th><th>Статус</th><th></th></tr>
</thead> </thead>
<tbody id="cam-rows"></tbody> <tbody id="cam-rows"></tbody>
</table> </table>
+23 -6
View File
@@ -1178,7 +1178,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="6" class="cam-edit">` + `<td colspan="7" 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 +1187,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 class="sw">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` + `<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
`</div>` + `</div>` +
`</td>` `</td>`
); );
@@ -1235,7 +1235,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="6" class="cam-edit">` + `<td colspan="7" 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 +1244,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 class="sw">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` + `<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "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>` +
@@ -1344,6 +1344,7 @@ const CamSettings = {
`<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><span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span></td>` + `<td><span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span></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> ` +
@@ -1354,14 +1355,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="6" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`; logTr.innerHTML = `<td colspan="7" 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="actions"><button data-act="add">+ Добавить</button></td>`; `<td class="actions"><button data-act="add">+ Добавить</button></td>`;
tb.appendChild(tr); tb.appendChild(tr);
@@ -1395,6 +1396,22 @@ const CamSettings = {
box.hidden = false; box.hidden = false;
} }
}; };
tb.onchange = async (e) => { // тумблер REC в строке — старт/стоп записи на диск
const cb = e.target.closest(".rec-toggle");
if (!cb) return;
const id = cb.dataset.id;
cb.disabled = true;
try {
const r = await fetch(`/api/cameras/${id}/record`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: cb.checked }),
});
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); }
else if (this.byId[id]) { this.byId[id].enabled = cb.checked; }
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
finally { cb.disabled = false; }
};
}, },
}; };