This commit is contained in:
jpmvaz
2026-09-13 20:24:20 +01:00
commit 2015c4911f
9 changed files with 4403 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera Station · Set up</title>
<style>
:root{
--chassis:#0e1116;
--panel:#161b22;
--panel-hi:#1e252e;
--edge:#2a323d;
--ink:#e6e4df;
--ink-dim:#7d8794;
--phosphor:#ffb347;
--tally:#ff3b30;
--good:#3ddc84;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
--sans: "Helvetica Neue", "Inter", "Segoe UI", system-ui, sans-serif;
}
*{box-sizing:border-box}
body{
margin:0;padding:28px 22px 60px;background:var(--chassis);color:var(--ink);
font-family:var(--mono);font-size:13px;line-height:1.5;
display:flex;flex-direction:column;gap:20px;align-items:center;
}
.wrap{width:100%;max-width:900px;display:flex;flex-direction:column;gap:18px}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
h1{font-size:13px;letter-spacing:.34em;text-transform:uppercase;margin:0;font-weight:600}
.sub{color:var(--ink-dim);font-size:11px;letter-spacing:.1em}
a.back{
color:var(--phosphor);text-decoration:none;font-size:11px;
letter-spacing:.18em;text-transform:uppercase;border-bottom:1px solid #4a3a1c;padding-bottom:2px;
}
a.back:hover{border-bottom-color:var(--phosphor)}
.note{
background:var(--panel);border:1px solid var(--edge);border-left:2px solid var(--phosphor);
padding:12px 15px;color:var(--ink-dim);font-size:11.5px;line-height:1.65;border-radius:2px;
}
.note b{color:var(--ink);font-weight:600}
.note.warn{border-left-color:var(--tally)}
.rows{display:flex;flex-direction:column;gap:10px}
.row{
background:var(--panel);border:1px solid var(--edge);border-radius:2px;
padding:13px 14px;display:grid;gap:10px;
grid-template-columns:minmax(130px,190px) 1fr auto;align-items:start;
}
.row[data-kind="rtsp"]{border-left:2px solid #3a4a63}
.row[data-kind="mjpeg"]{border-left:2px solid #4a3a1c}
label{display:flex;flex-direction:column;gap:5px;min-width:0}
label span{font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim)}
input{
font-family:var(--mono);font-size:12.5px;color:var(--ink);
background:var(--chassis);border:1px solid var(--edge);border-radius:2px;
padding:9px 10px;width:100%;min-width:0;
}
input:focus{outline:none;border-color:#4a5768;background:#0b0e12}
input::placeholder{color:#4d5561}
.rowbtns{display:flex;gap:5px;align-items:center;padding-top:19px}
button{
font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
color:var(--ink);background:var(--panel-hi);border:1px solid var(--edge);
border-radius:2px;padding:9px 12px;cursor:pointer;white-space:nowrap;
}
button:hover:not(:disabled){background:#28313d}
button:disabled{opacity:.35;cursor:default}
button.icon{padding:9px 10px;min-width:34px;text-align:center}
button.danger:hover:not(:disabled){border-color:var(--tally);color:var(--tally)}
button.primary{background:var(--phosphor);color:#171310;border-color:var(--phosphor);font-weight:700}
button.primary:hover:not(:disabled){background:#ffc266}
button.toggle{min-width:118px;justify-content:center}
button.toggle[data-on="yes"]{
background:#3a1512;border-color:var(--tally);color:#ff9d95;font-weight:700;
}
button.toggle[data-on="yes"]::before{
content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
background:var(--tally);margin-right:8px;vertical-align:middle;
animation:pulse 1.6s ease-in-out infinite;
}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
.storage{
background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:15px 16px;
display:flex;flex-direction:column;gap:12px;
}
.storage h2{margin:0;font-size:11px;letter-spacing:.24em;text-transform:uppercase;font-weight:600}
.bar{height:8px;background:var(--chassis);border:1px solid var(--edge);border-radius:2px;overflow:hidden}
.bar i{display:block;height:100%;background:var(--phosphor);transition:width .4s ease}
.bar[data-full="yes"] i{background:var(--tally)}
.usage{font-size:11px;color:var(--ink-dim);letter-spacing:.06em}
.usage b{color:var(--ink);font-weight:600}
.files{display:flex;flex-direction:column;gap:4px;max-height:230px;overflow:auto}
.files a{
display:flex;justify-content:space-between;gap:12px;text-decoration:none;
color:var(--ink-dim);font-size:11px;padding:6px 8px;border:1px solid transparent;border-radius:2px;
}
.files a:hover{color:var(--ink);border-color:var(--edge);background:var(--chassis)}
.files a span:last-child{color:var(--ink-dim);white-space:nowrap}
.result{
grid-column:1 / -1;font-size:11px;letter-spacing:.05em;padding:8px 10px;border-radius:2px;
background:var(--chassis);border:1px solid var(--edge);color:var(--ink-dim);
word-break:break-word;
}
.result[data-state="ok"]{color:var(--good);border-color:#1d4530}
.result[data-state="bad"]{color:#ff8a80;border-color:#4d2320}
.result[data-state="busy"]{color:var(--phosphor)}
.result[hidden]{display:none}
.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.actions .spacer{flex:1}
.status{font-size:11px;letter-spacing:.08em;color:var(--ink-dim)}
.status[data-state="ok"]{color:var(--good)}
.status[data-state="bad"]{color:#ff8a80}
.empty{
border:1px dashed var(--edge);border-radius:2px;padding:26px;text-align:center;
color:var(--ink-dim);font-size:11.5px;
}
@media (max-width:720px){
.row{grid-template-columns:1fr}
.rowbtns{padding-top:0;flex-wrap:wrap}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Camera Station · Set up</h1>
<div class="sub" id="sub">Loading…</div>
</div>
<nav style="display:flex;gap:10px">
<a class="back" href="/files">Files</a>
<a class="back" href="/">&larr; Viewer</a>
</nav>
</header>
<div class="note" id="hint">
Add as many cameras as you like. An address starting <b>http://</b> is treated as an
MJPEG feed and relayed untouched; one starting <b>rtsp://</b> is decoded by ffmpeg on
this machine. <b>Test</b> pulls a single frame so you can check a camera before saving.
</div>
<div class="note" id="ffmpegWarn" hidden>
ffmpeg was not found, so RTSP cameras cannot be decoded. MJPEG cameras still work.
</div>
<div class="note warn" id="lockWarn" hidden>
This server was started with <b>--lock-config</b>, so cameras can be viewed here but not changed.
</div>
<div class="rows" id="rows"></div>
<div class="empty" id="empty" hidden>No cameras yet. Add one below.</div>
<div class="actions">
<button id="add">+ Add camera</button>
<button id="testAll">Test all</button>
<span class="spacer"></span>
<span class="status" id="status"></span>
<button id="revert">Revert</button>
<button class="primary" id="save">Save changes</button>
</div>
<div class="storage">
<h2>Stored footage &nbsp;<a href="/files" style="color:var(--phosphor);text-decoration:none;font-size:10px;letter-spacing:.18em">browse all &rarr;</a></h2>
<div class="bar" id="bar"><i style="width:0"></i></div>
<div class="usage" id="usage">Reading…</div>
<div class="files" id="files"></div>
</div>
<div class="note">
Recording a camera writes its stream straight to disk without re-encoding, so the
files are exactly what the camera sent and cost very little CPU. Footage is cut into
timestamped pieces; once the size limit is reached the oldest pieces are deleted so
the total never grows past it.
</div>
<div class="note">
Saved passwords are shown as bullets and are never sent to this page. Leave the bullets
alone to keep the existing password, or type over them to set a new one.
Cameras are stored in <b>cameras.json</b> next to server.py.
</div>
</div>
<script>
(function () {
"use strict";
var rowsEl = document.getElementById("rows");
var emptyEl = document.getElementById("empty");
var statusEl = document.getElementById("status");
var subEl = document.getElementById("sub");
var saveBtn = document.getElementById("save");
var revertBtn = document.getElementById("revert");
var addBtn = document.getElementById("add");
var testAllBtn = document.getElementById("testAll");
var cameras = []; // what is on screen
var saved = []; // what the server last confirmed
var locked = false;
var ffmpegOk = true;
function kindOf(url) {
return /^rtsps?:\/\//i.test(url || "") ? "rtsp" : "mjpeg";
}
function setStatus(text, state) {
statusEl.textContent = text || "";
if (state) statusEl.dataset.state = state; else delete statusEl.dataset.state;
}
function dirty() {
return JSON.stringify(cameras.map(function (c) { return [c.name, c.url]; })) !==
JSON.stringify(saved.map(function (c) { return [c.name, c.url]; }));
}
function refreshButtons() {
var changed = dirty();
saveBtn.disabled = locked || !changed;
revertBtn.disabled = !changed;
addBtn.disabled = locked;
emptyEl.hidden = cameras.length > 0;
}
function render() {
rowsEl.textContent = "";
cameras.forEach(function (cam, index) {
var row = document.createElement("div");
row.className = "row";
row.dataset.kind = kindOf(cam.url);
var nameLabel = document.createElement("label");
var nameSpan = document.createElement("span");
nameSpan.textContent = "Name";
var nameInput = document.createElement("input");
nameInput.value = cam.name;
nameInput.placeholder = "Camera " + (index + 1);
nameInput.disabled = locked;
nameInput.addEventListener("input", function () {
cam.name = nameInput.value;
refreshButtons();
});
nameLabel.appendChild(nameSpan);
nameLabel.appendChild(nameInput);
var urlLabel = document.createElement("label");
var urlSpan = document.createElement("span");
urlSpan.textContent = "Address";
var urlInput = document.createElement("input");
urlInput.value = cam.url;
urlInput.spellcheck = false;
urlInput.placeholder = "http://192.168.1.50:8080 or rtsp://user:pass@192.168.1.60:554/stream";
urlInput.disabled = locked;
urlInput.addEventListener("input", function () {
cam.url = urlInput.value;
row.dataset.kind = kindOf(cam.url);
refreshButtons();
});
urlLabel.appendChild(urlSpan);
urlLabel.appendChild(urlInput);
var btns = document.createElement("div");
btns.className = "rowbtns";
var rec = document.createElement("button");
rec.className = "toggle";
rec.dataset.on = cam.active ? "yes" : "no";
rec.textContent = cam.active ? "Recording" : "Not recording";
rec.title = cam.active
? "This camera is being recorded to disk continuously. Click to stop."
: "Click to record this camera continuously, even when nobody is watching.";
rec.disabled = locked || !cam.id;
rec.addEventListener("click", function () { toggleActive(cam, rec); });
var testBtn = document.createElement("button");
testBtn.textContent = "Test";
testBtn.addEventListener("click", function () { testOne(cam, row, testBtn); });
var up = document.createElement("button");
up.className = "icon"; up.textContent = "\u2191";
up.title = "Move up";
up.disabled = locked || index === 0;
up.addEventListener("click", function () { move(index, -1); });
var down = document.createElement("button");
down.className = "icon"; down.textContent = "\u2193";
down.title = "Move down";
down.disabled = locked || index === cameras.length - 1;
down.addEventListener("click", function () { move(index, 1); });
var del = document.createElement("button");
del.className = "icon danger"; del.textContent = "\u00d7";
del.title = "Remove this camera";
del.disabled = locked;
del.addEventListener("click", function () {
cameras.splice(index, 1);
render();
setStatus("Removed. Save to apply.", null);
});
btns.appendChild(rec); btns.appendChild(testBtn); btns.appendChild(up);
btns.appendChild(down); btns.appendChild(del);
var result = document.createElement("div");
result.className = "result";
result.hidden = true;
row.appendChild(nameLabel);
row.appendChild(urlLabel);
row.appendChild(btns);
row.appendChild(result);
rowsEl.appendChild(row);
});
refreshButtons();
}
function toggleActive(cam, button) {
if (!cam.id) {
setStatus("Save this camera first, then it can be recorded.", "bad");
return;
}
button.disabled = true;
fetch("/api/active", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: cam.id, active: !cam.active })
}).then(function (r) {
return r.text().then(function (body) {
if (!r.ok) throw new Error(body || "The server refused that.");
return JSON.parse(body);
});
}).then(function (state) {
cam.active = state.active;
var match = saved.filter(function (c) { return c.id === cam.id; })[0];
if (match) match.active = state.active; // this is saved immediately, so it is not a pending edit
button.dataset.on = cam.active ? "yes" : "no";
button.textContent = cam.active ? "Recording" : "Not recording";
setStatus(cam.active
? cam.name + " is now recorded continuously."
: cam.name + " is no longer being recorded.", "ok");
loadStorage();
}).catch(function (err) {
setStatus(String(err.message || err), "bad");
}).finally(function () {
button.disabled = locked;
refreshButtons();
});
}
function move(index, delta) {
var target = index + delta;
if (target < 0 || target >= cameras.length) return;
var moved = cameras.splice(index, 1)[0];
cameras.splice(target, 0, moved);
render();
}
function showResult(row, text, state) {
var box = row.querySelector(".result");
box.hidden = false;
box.textContent = text;
if (state) box.dataset.state = state; else delete box.dataset.state;
}
function testOne(cam, row, button) {
showResult(row, "Pulling a frame\u2026", "busy");
if (button) button.disabled = true;
return fetch("/api/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: cam.url })
}).then(function (r) { return r.json(); })
.then(function (res) {
if (res.ok) {
var size = res.width ? res.width + "\u00d7" + res.height : "picture received";
showResult(row, "Working \u00b7 " + (res.kind || "") + " \u00b7 " + size, "ok");
} else {
showResult(row, res.message || "No picture came back.", "bad");
}
})
.catch(function () { showResult(row, "Could not reach this server.", "bad"); })
.finally(function () { if (button) button.disabled = false; });
}
function testAll() {
var rows = rowsEl.querySelectorAll(".row");
testAllBtn.disabled = true;
// One at a time: several RTSP probes at once would fight for the CPU and
// make each other look slow.
var chain = Promise.resolve();
cameras.forEach(function (cam, i) {
chain = chain.then(function () { return testOne(cam, rows[i], null); });
});
chain.finally(function () { testAllBtn.disabled = false; });
}
function save() {
setStatus("Saving\u2026", null);
saveBtn.disabled = true;
fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cameras: cameras })
}).then(function (r) {
return r.text().then(function (body) {
if (!r.ok) throw new Error(body || ("Save failed (" + r.status + ")"));
return JSON.parse(body);
});
}).then(function (res) {
cameras = res.cameras.map(function (c) { return Object.assign({}, c); });
saved = res.cameras.map(function (c) { return Object.assign({}, c); });
render();
setStatus("Saved. The viewer updates within a few seconds.", "ok");
}).catch(function (err) {
setStatus(String(err.message || err), "bad");
refreshButtons();
});
}
addBtn.addEventListener("click", function () {
cameras.push({ id: "", name: "", url: "" });
render();
var inputs = rowsEl.querySelectorAll(".row:last-child input");
if (inputs.length) inputs[0].focus();
});
revertBtn.addEventListener("click", function () {
cameras = saved.map(function (c) { return Object.assign({}, c); });
render();
setStatus("Reverted to the saved cameras.", null);
});
saveBtn.addEventListener("click", save);
testAllBtn.addEventListener("click", testAll);
window.addEventListener("beforeunload", function (e) {
if (dirty()) { e.preventDefault(); e.returnValue = ""; }
});
function human(n) {
if (!n) return "0 B";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
return (n / 1073741824).toFixed(2) + " GB";
}
function when(ts) {
if (!ts) return "";
var d = new Date(ts * 1000);
return d.toLocaleString();
}
function loadStorage() {
fetch("/api/storage")
.then(function (r) { return r.json(); })
.then(function (s) {
var pct = s.limit ? Math.min(100, (s.used / s.limit) * 100) : 0;
var bar = document.getElementById("bar");
bar.firstElementChild.style.width = pct.toFixed(1) + "%";
bar.dataset.full = pct > 92 ? "yes" : "no";
var parts = [human(s.used) + " of " + human(s.limit),
s.segments + (s.segments === 1 ? " file" : " files")];
if (s.photoCount) parts.push(s.photoCount + " photos (" + human(s.photoBytes) + ")");
document.getElementById("usage").innerHTML =
"<b>" + parts[0] + "</b> \u00b7 " + parts.slice(1).join(" \u00b7 ");
var list = document.getElementById("files");
list.textContent = "";
(s.recent || []).forEach(function (f) {
var a = document.createElement("a");
a.href = "/recordings/" + encodeURIComponent(f.folder) + "/" + encodeURIComponent(f.name);
var left = document.createElement("span");
left.textContent = f.folder + " \u00b7 " + f.name;
var right = document.createElement("span");
right.textContent = human(f.size) + " " + when(f.mtime);
a.appendChild(left); a.appendChild(right);
list.appendChild(a);
});
if (!(s.recent || []).length) {
var none = document.createElement("a");
none.textContent = "Nothing recorded yet.";
none.removeAttribute("href");
list.appendChild(none);
}
})
.catch(function () {
document.getElementById("usage").textContent = "Could not read storage.";
});
}
loadStorage();
setInterval(loadStorage, 10000);
fetch("/api/config")
.then(function (r) { return r.json(); })
.then(function (data) {
locked = !!data.locked;
ffmpegOk = data.ffmpeg !== false;
cameras = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
saved = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
document.getElementById("lockWarn").hidden = !locked;
document.getElementById("ffmpegWarn").hidden = ffmpegOk;
subEl.textContent = cameras.length + (cameras.length === 1 ? " camera" : " cameras");
render();
})
.catch(function () {
subEl.textContent = "Could not load the current cameras.";
setStatus("Is server.py still running?", "bad");
});
})();
</script>
</body>
</html>