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
+81 -15
View File
@@ -34,6 +34,7 @@
<main class="settings-main">
<div class="tabs">
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
<button class="tab" data-tab="storage" data-min-role="admin">Хранилища</button>
<button class="tab" data-tab="layout">Раскладка</button>
<button class="tab" data-tab="player" data-min-role="operator">Плеер</button>
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
@@ -62,25 +63,90 @@
</div>
</div>
<!-- ВКЛАДКА: Хранилища (управление записью) -->
<div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
<div class="card">
<div class="card-head">
<h2>Хранилища записи</h2>
<button id="stor-add">+ Добавить хранилище</button>
</div>
<div class="muted" style="margin-bottom:12px">
Активное хранилище — куда пишутся новые записи. Квота задаётся на каждое хранилище
(0 = без лимита) и правится прямо в таблице. После смены активного старые записи
остаются доступны в Архиве. Удаление хранилища убирает его из списка — файлы записей
НЕ удаляются.
</div>
<table class="cams">
<thead>
<tr>
<th style="width:74px">Активно</th>
<th style="width:90px">ID</th>
<th>Название</th>
<th style="width:56px">Тип</th>
<th>Путь</th>
<th style="width:130px">Квота, ГБ</th>
<th style="width:150px">Занято / диск</th>
<th style="width:130px">Статус</th>
<th style="width:48px"></th>
</tr>
</thead>
<tbody id="stor-rows"></tbody>
</table>
<div class="muted" id="stor-msg" style="margin-top:10px;font-size:12px"></div>
</div>
<!-- форма добавления (скрыта по умолчанию) -->
<div class="card" id="stor-form-card" hidden>
<h2>Новое хранилище</h2>
<div class="cam-edit-grid">
<label>ID<input type="text" id="sf-id" placeholder="arch2"></label>
<label>Название<input type="text" id="sf-name" placeholder="Архив 2"></label>
<label>Тип
<select id="sf-type">
<option value="local">Локальное (папка)</option>
<option value="nas">NAS (SMB)</option>
</select>
</label>
<label>Квота, ГБ (0 = без лимита)<input type="number" id="sf-quota" min="0" value="0"></label>
<label class="sf-local">Папка<input type="text" id="sf-path" placeholder="/storages/arch2 (по умолчанию)"></label>
<label class="sf-nas" hidden>SMB сервер (IP)<input type="text" id="sf-server" placeholder="192.168.1.10"></label>
<label class="sf-nas" hidden>Шара (share)<input type="text" id="sf-share" placeholder="cctv"></label>
<label class="sf-nas" hidden>Логин<input type="text" id="sf-user"></label>
<label class="sf-nas" hidden>Пароль<input type="password" id="sf-pass"></label>
<label class="sf-nas" hidden>Подпапка (необяз.)<input type="text" id="sf-subpath" placeholder="cam-archive"></label>
</div>
<div class="cam-edit-foot">
<span class="err" id="sf-err"></span>
<button id="sf-save" class="primary">Создать</button>
<button id="sf-cancel">Отмена</button>
</div>
<div class="muted" id="sf-mount" style="margin-top:10px;font-size:12px;white-space:pre-wrap"></div>
</div>
<div class="card">
<h2>Политика записи <span class="muted" style="font-weight:400;font-size:12px">— общая для всех хранилищ</span></h2>
<div class="cam-edit-grid">
<label>Длина сегмента
<select id="pol-segment">
<option value="60">1 минута</option>
<option value="120">2 минуты</option>
<option value="300">5 минут</option>
<option value="600">10 минут</option>
<option value="900">15 минут</option>
</select>
</label>
<label>Срок хранения, дней (0 = не удалять по возрасту)<input type="number" id="pol-retention" min="0" max="3650"></label>
</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>
</div>
<!-- ВКЛАДКА 2: Система -->
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
<div class="wrap">
<div class="card" data-min-role="admin">
<h2>Запись</h2>
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
<select id="segment-size" style="margin-top:6px;display:block">
<option value="60">1 минута</option>
<option value="120">2 минуты</option>
<option value="300">5 минут</option>
<option value="600">10 минут</option>
<option value="900">15 минут</option>
</select>
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
</div>
<div class="card">
<h2>Хранилище</h2>
<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>
+184 -26
View File
@@ -1441,7 +1441,7 @@ const Settings = {
if (meEl && CURRENT_USER)
meEl.innerHTML = `Вы вошли как <b>${CURRENT_USER.username}</b> — ` +
(ROLE_RU[CURRENT_USER.role] || CURRENT_USER.role);
await Promise.all([this.loadSystem(), this.loadPrefs(), this.loadStorage()]);
await Promise.all([this.loadSystem(), this.loadPrefs()]);
if (roleAtLeast("superadmin")) this.loadOptim();
setInterval(() => this.loadSystem(), 5000);
},
@@ -1484,31 +1484,6 @@ const Settings = {
};
},
async loadStorage() {
const s = await api("/api/system");
const sel = document.getElementById("segment-size");
const saved = document.getElementById("segment-saved");
const btn = document.getElementById("segment-save");
sel.value = String(s.segment_seconds);
btn.onclick = async () => {
btn.disabled = true; saved.textContent = "Сохранение…";
try {
const r = await fetch("/api/storage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ segment_seconds: +sel.value }),
});
if (r.ok) {
saved.textContent = "Сохранено ✓ рекордеры перезапущены";
setTimeout(() => { saved.textContent = ""; }, 3000);
} else {
const d = await r.json().catch(() => ({}));
saved.textContent = d.detail || ("Ошибка " + r.status);
}
} finally { btn.disabled = false; }
};
},
trRowHtml(pr) { // ряд параметров одного профиля
const sel = (f, opts) => `<select data-f="${f}">` + opts.map(([v, t]) =>
`<option value="${v}"${String(pr[f]) === String(v) ? " selected" : ""}>${t}</option>`).join("") + `</select>`;
@@ -1618,6 +1593,188 @@ const Settings = {
kv(k, v) { return `<div class="k">${k}</div><div class="v">${v}</div>`; },
};
// ════════════════════════════════════════════════════════════════
// Вкладка ХРАНИЛИЩА (несколько хранилищ + выбор активного)
// ════════════════════════════════════════════════════════════════
const Storages = {
data: { storages: [], active: "", retention_days: 7, segment_seconds: 600 },
async init() {
const addBtn = document.getElementById("stor-add");
if (!addBtn) return; // нет прав / не на странице настроек
addBtn.onclick = () => this.toggleForm(true);
const cancel = document.getElementById("sf-cancel");
if (cancel) cancel.onclick = () => this.toggleForm(false);
const typeSel = document.getElementById("sf-type");
if (typeSel) typeSel.onchange = () => this.syncFormType();
const save = document.getElementById("sf-save");
if (save) save.onclick = () => this.create();
const pol = document.getElementById("pol-save");
if (pol) pol.onclick = () => this.savePolicy();
await this.load();
},
esc(s) { return String(s == null ? "" : s).replace(/"/g, "&quot;"); },
toggleForm(show) {
const card = document.getElementById("stor-form-card");
if (!card) return;
card.hidden = !show;
if (show) {
["sf-id", "sf-name", "sf-path", "sf-server", "sf-share", "sf-user", "sf-pass", "sf-subpath"]
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ""; });
document.getElementById("sf-quota").value = "0";
document.getElementById("sf-type").value = "local";
document.getElementById("sf-err").textContent = "";
document.getElementById("sf-mount").textContent = "";
this.syncFormType();
document.getElementById("sf-id").focus();
}
},
syncFormType() {
const nas = document.getElementById("sf-type").value === "nas";
document.querySelectorAll(".sf-nas").forEach((el) => { el.hidden = !nas; });
document.querySelectorAll(".sf-local").forEach((el) => { el.hidden = nas; });
},
async load() {
let d;
try { d = await api("/api/storages"); } catch (e) { return; }
this.data = d;
this.render();
const seg = document.getElementById("pol-segment");
if (seg) seg.value = String(d.segment_seconds);
const ret = document.getElementById("pol-retention");
if (ret) ret.value = String(d.retention_days);
},
render() {
const tb = document.getElementById("stor-rows");
if (!tb) return;
const v = (x) => this.esc(x);
tb.innerHTML = "";
(this.data.storages || []).forEach((s) => {
const tr = document.createElement("tr");
tr.dataset.id = s.id;
const typeRu = s.type === "nas" ? "NAS" : "локал";
const used = fmtSize(s.used_bytes || 0);
const disk = s.disk ? `${s.disk.used_gb} / ${s.disk.total_gb} ГБ` : "—";
const statusCell = s.type === "nas"
? `<span class="badge"><span class="${dotClass(s.mounted ? "online" : "offline")}"></span>${s.mounted ? "смонтировано" : "не смонтировано"}</span>`
: `<span class="muted">—</span>`;
const activeCell = s.active
? `<span class="badge badge-active">активно</span>`
: `<button data-act="activate" data-id="${v(s.id)}">Выбрать</button>`;
tr.innerHTML =
`<td>${activeCell}</td>` +
`<td class="muted">${v(s.id)}</td>` +
`<td>${v(s.name)}</td>` +
`<td class="muted">${typeRu}</td>` +
`<td class="muted" title="${v(s.path)}">${v(s.path)}</td>` +
`<td><span class="q-cell"><input type="number" class="q-input" data-id="${v(s.id)}" min="0" value="${s.quota_gb}">` +
`<button data-act="save-quota" data-id="${v(s.id)}" title="Сохранить квоту">✓</button></span></td>` +
`<td class="muted">${used} / ${disk}</td>` +
`<td>${statusCell}</td>` +
`<td class="actions">${s.active ? "" : `<button data-act="delete" data-id="${v(s.id)}" title="Убрать из списка (файлы не удаляются)">✕</button>`}</td>`;
tb.appendChild(tr);
});
tb.onclick = (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const id = btn.dataset.id, act = btn.dataset.act;
if (act === "activate") this.activate(id);
else if (act === "delete") this.remove(id);
else if (act === "save-quota") this.saveQuota(id);
};
},
async activate(id) {
const s = (this.data.storages || []).find((x) => x.id === id);
if (s && s.type === "nas" && !s.mounted &&
!confirm("NAS не смонтирован. Запись начнётся только после монтирования на хосте. Всё равно сделать активным?")) return;
const r = await fetch(`/api/storages/${id}/activate`, { method: "POST" });
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
await this.load();
this.msg("Активное хранилище изменено — рекордеры перезапущены");
},
async remove(id) {
if (!confirm(`Убрать хранилище «${id}» из списка?\nФайлы записей НЕ удаляются — архив сохранится.`)) return;
const r = await fetch(`/api/storages/${id}`, { method: "DELETE" });
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
await this.load();
},
async saveQuota(id) {
const inp = document.querySelector(`.q-input[data-id="${CSS.escape(id)}"]`);
if (!inp) return;
const q = Math.max(0, parseInt(inp.value, 10) || 0);
const r = await fetch(`/api/storages/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quota_gb: q }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
this.msg(`Квота «${id}»: ${q ? q + " ГБ" : "без лимита"}`);
await this.load();
},
async create() {
const val = (id) => { const el = document.getElementById(id); return el ? el.value : ""; };
const type = val("sf-type");
const payload = {
id: val("sf-id").trim(), name: val("sf-name").trim(), type,
quota_gb: Math.max(0, parseInt(val("sf-quota"), 10) || 0),
};
if (type === "local") {
const p = val("sf-path").trim(); if (p) payload.path = p;
} else {
payload.server = val("sf-server").trim();
payload.share = val("sf-share").trim();
payload.username = val("sf-user").trim();
payload.password = val("sf-pass");
payload.subpath = val("sf-subpath").trim();
}
const err = document.getElementById("sf-err"); err.textContent = "";
const r = await fetch("/api/storages", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { err.textContent = d.detail || ("Ошибка " + r.status); return; }
if (d.mount_help) {
document.getElementById("sf-mount").textContent =
"Хранилище создано. Смонтируйте SMB на ХОСТЕ (.79):\n\n" +
d.mount_help.mount_cmd + "\n\nАвтомонтаж — строка в /etc/fstab:\n" +
d.mount_help.fstab + "\n\n" + (d.mount_help.note || "");
this.msg("NAS-хранилище создано — смонтируйте том на хосте");
} else {
this.toggleForm(false);
}
await this.load();
},
async savePolicy() {
const seg = +document.getElementById("pol-segment").value;
const ret = Math.max(0, parseInt(document.getElementById("pol-retention").value, 10) || 0);
const saved = document.getElementById("pol-saved"); saved.textContent = "Сохранение…";
const r = await fetch("/api/storages/policy", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ segment_seconds: seg, retention_days: ret }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { saved.textContent = d.detail || ("Ошибка " + r.status); return; }
saved.textContent = "Сохранено ✓ (смена длины сегмента перезапускает рекордеры)";
clearTimeout(this._t); this._t = setTimeout(() => { saved.textContent = ""; }, 4000);
await this.load();
},
msg(t) {
const m = document.getElementById("stor-msg"); if (!m) return;
m.textContent = t; clearTimeout(this._m); this._m = setTimeout(() => { m.textContent = ""; }, 4000);
},
};
// ════════════════════════════════════════════════════════════════
// Страница РАСКЛАДКА (назначение камер по ячейкам)
// ════════════════════════════════════════════════════════════════
@@ -1807,6 +1964,7 @@ document.addEventListener("DOMContentLoaded", async () => {
initTabs();
CamSettings.init();
Settings.init();
if (roleAtLeast("admin")) Storages.init();
LayoutEditor.init();
if (roleAtLeast("superadmin")) Users.init();
}
+4
View File
@@ -441,6 +441,10 @@ table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
border-radius: 11px; font-size: 12px; background: var(--panel-2);
}
.badge-active { background: rgba(46, 204, 113, .18); color: var(--ok); font-weight: 600; }
.q-cell { display: inline-flex; align-items: center; gap: 6px; }
.q-cell .q-input { width: 78px; }
.q-cell button { padding: 2px 8px; font-size: 12px; }
.logbox {
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;