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
+6 -6
View File
@@ -56,7 +56,7 @@
</div>
<table class="cams">
<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>
<tbody id="cam-rows"></tbody>
</table>
@@ -140,16 +140,16 @@
<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 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>
<!-- ВКЛАДКА 2: Система -->
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
<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">
<h2>Система</h2>
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
+45 -14
View File
@@ -1123,6 +1123,7 @@ const CamSettings = {
bulk: false, // режим массового редактирования всей таблицы
cams: [],
byId: {},
storages: [], // список хранилищ для выпадающего выбора в строке камеры
async init() {
const add = document.getElementById("add-cam");
@@ -1133,10 +1134,24 @@ const CamSettings = {
if (bc) bc.onclick = () => this.exitBulk();
const cc = document.getElementById("clear-cams");
if (cc) cc.onclick = () => this.clearAll();
try { this.storages = (await api("/api/storages")).storages || []; } catch (e) { this.storages = []; }
await 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() {
const be = document.getElementById("bulk-edit");
const bc = document.getElementById("bulk-cancel");
@@ -1183,7 +1198,7 @@ const CamSettings = {
bulkRowHtml(slotNo, cam) {
const v = (x) => this.esc(x);
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-edit-grid" data-cam="${v(cam.id)}">` +
`<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="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>Хранилище<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="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` +
`</div>` +
@@ -1213,6 +1229,7 @@ const CamSettings = {
password: get("password"),
main_path: get("main_path").trim(),
sub_path: get("sub_path").trim(),
storage_id: get("storage_id"),
enabled: g.querySelector('[data-f="enabled"]').checked,
record: g.querySelector('[data-f="record"]').checked,
};
@@ -1221,6 +1238,7 @@ const CamSettings = {
payload.user !== orig.user || payload.password !== (orig.password || "") ||
payload.main_path !== orig.main_path ||
(payload.sub_path || "") !== (orig.sub_path || "") ||
(payload.storage_id || "") !== (orig.storage_id || "") ||
payload.enabled !== orig.enabled ||
payload.record !== orig.record;
if (!changed) continue;
@@ -1243,7 +1261,7 @@ const CamSettings = {
const v = (x) => this.esc(x);
const isEdit = !!cam;
return (
`<td colspan="8" class="cam-edit">` +
`<td colspan="9" class="cam-edit">` +
`<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>Имя<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="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>Хранилище<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="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
`</div>` +
@@ -1295,6 +1314,7 @@ const CamSettings = {
password: val("e-pass"),
main_path: val("e-main").trim(),
sub_path: val("e-sub").trim(),
storage_id: val("e-storage"),
enabled: document.getElementById("e-enabled").checked,
record: document.getElementById("e-record").checked,
};
@@ -1359,6 +1379,7 @@ const CamSettings = {
`<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="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 class="actions">` +
`<button data-act="log" data-id="${c.id}">Лог</button> ` +
@@ -1369,14 +1390,14 @@ const CamSettings = {
const logTr = document.createElement("tr");
logTr.className = "logrow";
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);
} else {
tr.className = "slot-empty";
tr.dataset.slot = i + 1;
tr.innerHTML =
`<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="actions"><button data-act="add">+ Добавить</button></td>`;
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 cb = en || e.target.closest(".rec-toggle");
if (!cb) return;
const id = cb.dataset.id;
const patch = en ? { enabled: cb.checked } : { record: cb.checked };
cb.disabled = true;
const rec = e.target.closest(".rec-toggle");
const stor = e.target.closest(".stor-sel");
const ctrl = en || rec || stor;
if (!ctrl) return;
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 {
const r = await fetch(`/api/cameras/${id}/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" },
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);
await this.load(); // перерисовать: REC зависит от состояния «Вкл»
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
finally { cb.disabled = false; }
await this.load(); // перерисовать (REC зависит от «Вкл», строка хранилища)
} catch (err) { revert(); alert("Сбой запроса"); }
finally { ctrl.disabled = false; }
};
},
};