From 449b1e75e0679f1558c5d6c129efe198a32b2fef Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 8 Aug 2026 23:29:51 +0200
Subject: [PATCH] Dashboard: read the reports, and create accounts without the
admin UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two views on status.html, and one migration.
Hlášení: one incident per card rather than a table row, because a report
carries fifteen numbers and the job is to read one incident, not scan a
column. The reporter's address is resolved from the `user` relation in the
browser — the app never writes identity onto telemetry rows. The box is
named. Our own test sessions are HIDDEN by default, since the question
this view answers is what the family experienced; `tester` OR an emulator
tell OR a test address, so it stays right for rows written before the
column existed. Day-range filter, and every field says "neměří se" rather
than 0 when that player does not send it — a different claim from zero,
and the TV sends everything while mpv sends part.
Správa: create an account and flip clean/18+. Registration stays CLOSED —
`users.createRule` is still null and this is a plain POST with the
superuser token the page has held since it was written, so it grants
nothing that was not already available in the PocketBase admin UI. The
password field is deliberately visible: you are making it for someone
else and have to read it out.
client_logs.gitSha: which BUILD wrote a row, not which version. Fourth
outing for the silent-drop lesson, and written alongside the client change
rather than discovered after.
Verified against real data before deploying, by splicing production rows
into the shipped page as fixtures and driving it in a browser: seven
reports render with the right reporter and box, the day filter and the
test toggle both work, prefs persist, and the create form's guard fires.
That caught a JS syntax error (a Czech quote closed with an ASCII one
inside a double-quoted string, which would have taken the whole dashboard
down — PocketBase serves this file with no build step) and a missing space
that rendered "ďáblashlédnuto".
---
.../1790000000_client_logs_gitsha.js | 42 ++
pb_public/status.html | 420 +++++++++++++++++-
2 files changed, 445 insertions(+), 17 deletions(-)
create mode 100644 pb_migrations/1790000000_client_logs_gitsha.js
diff --git a/pb_migrations/1790000000_client_logs_gitsha.js b/pb_migrations/1790000000_client_logs_gitsha.js
new file mode 100644
index 0000000..3ffd4e3
--- /dev/null
+++ b/pb_migrations/1790000000_client_logs_gitsha.js
@@ -0,0 +1,42 @@
+///
+
+// Add `gitSha` to client_logs: which BUILD wrote this row, not which version.
+//
+// WHY THIS MIGRATION EXISTS: `1.1.7+18` was built and installed at least four
+// times in one evening with materially different code in it. Two telemetry fields
+// were broken in the earlier builds — `lastLoadError` recorded a single obfuscated
+// letter and throughput reported -1 — and nothing in the stored rows could tell
+// those builds apart from the fixed one. So a real measurement from a real
+// television had to be discarded as "probably the old build", which is exactly the
+// kind of ambiguity a diagnostic pipeline must not have.
+//
+// A version number records an intent to release. The sha records the artifact.
+//
+// Fourth time the silent-drop lesson applies (hidden `profiles.pinHash`,
+// `client_logs.flavor`, `client_logs.tester`, now this): PocketBase discards an
+// undeclared key in a create payload with no error and HTTP 200, so a field the
+// client sends does not exist until a migration declares it. Written at the same
+// time as the client change rather than discovered afterwards.
+//
+// Not required, and empty in an ad-hoc local build. An absent sha honestly says
+// "unknown"; the release scripts pass `--dart-define=GIT_SHA=$(git rev-parse
+// --short HEAD)` so every published artifact carries one.
+
+migrate(
+ (app) => {
+ const logs = app.findCollectionByNameOrId("client_logs")
+ logs.fields.add(
+ new TextField({
+ name: "gitSha",
+ required: false,
+ max: 20,
+ }),
+ )
+ app.save(logs)
+ },
+ (app) => {
+ const logs = app.findCollectionByNameOrId("client_logs")
+ logs.fields.removeByName("gitSha")
+ app.save(logs)
+ },
+)
diff --git a/pb_public/status.html b/pb_public/status.html
index 6cee48d..1c2b5f8 100644
--- a/pb_public/status.html
+++ b/pb_public/status.html
@@ -43,6 +43,7 @@
.ok{background:rgba(95,208,138,.14);color:var(--good)}
.un{background:rgba(154,160,170,.16);color:var(--muted)}
.no{background:rgba(255,107,107,.16);color:var(--bad)}
+ .wn{background:rgba(240,166,60,.16);color:var(--warn)}
.big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums}
.kv{display:flex;gap:18px;flex-wrap:wrap}
.kv div{min-width:78px}
@@ -52,11 +53,14 @@
button:hover{border-color:var(--accent2)}
button.primary{background:var(--accent);color:var(--goldbg);border-color:var(--accent);font-weight:600}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
- input{background:var(--panel2);color:var(--text);border:1px solid var(--line);
+ input,select{background:var(--panel2);color:var(--text);border:1px solid var(--line);
border-radius:8px;padding:9px 11px;font:inherit;width:100%}
+ select{padding:7px 9px;width:auto;font-size:13px}
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
.warnbox{background:rgba(240,166,60,.10);border:1px solid rgba(240,166,60,.35);
border-radius:8px;padding:10px 12px;color:var(--text);font-size:13px;margin-top:8px}
+ .okbox{background:rgba(95,208,138,.10);border:1px solid rgba(95,208,138,.35);
+ border-radius:8px;padding:10px 12px;font-size:13px;margin-top:8px}
pre{margin:8px 0 0;background:#0b0c10;border:1px solid var(--line);border-radius:8px;
padding:10px;max-height:340px;overflow:auto;font-family:var(--mono);font-size:11.5px;
white-space:pre-wrap;word-break:break-word}
@@ -80,6 +84,44 @@
.sub h3{margin:0 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);font-weight:600}
.rate{font-weight:600}
+
+ /* ── the three views ─────────────────────────────────────────────────────── */
+ /* A segmented control rather than links: this is one page with one token, and
+ a reload would drop the sessionStorage-only session on some browsers. */
+ .tabs{display:flex;gap:2px;background:var(--panel2);border:1px solid var(--line);
+ border-radius:9px;padding:2px}
+ .tabs button{border:none;background:transparent;border-radius:7px;padding:6px 12px;
+ color:var(--muted);font-size:13px}
+ .tabs button[aria-current="true"]{background:var(--accent);color:var(--goldbg);font-weight:600}
+ .tabs button:hover{border-color:transparent;color:var(--text)}
+ .tabs button[aria-current="true"]:hover{color:var(--goldbg)}
+ .ctl{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
+ .ctl label{color:var(--muted);font-size:12.5px;display:flex;align-items:center;gap:6px}
+ .ctl input[type=checkbox]{width:auto;padding:0;accent-color:var(--accent)}
+
+ /* A report is a card, not a table row: it carries fifteen numbers and the point
+ is to read one incident, not to scan a column. */
+ .rep{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px;
+ background:var(--panel2)}
+ .rep.test{opacity:.62}
+ .rep .hd{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:8px}
+ .rep .who{font-weight:600}
+ .rep .when{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
+ .rep .box{color:var(--muted);font-size:12px;font-family:var(--mono)}
+ .facts{display:grid;gap:6px 18px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
+ font-size:13px}
+ .facts .f{display:flex;gap:6px;justify-content:space-between;
+ border-bottom:1px solid rgba(42,45,54,.6);padding-bottom:3px}
+ .facts .k{color:var(--muted);font-size:12px}
+ .facts .v{font-family:var(--mono);font-size:12.5px;text-align:right}
+ .facts .v.hi{color:var(--warn);font-weight:600}
+ .facts .v.lo{color:var(--muted)}
+ .facts .v.bad{color:var(--bad);font-weight:600}
+ details.raw{margin-top:8px}
+ details.raw summary{color:var(--muted);font-size:12px;cursor:pointer}
+ .form{display:grid;gap:10px;max-width:420px}
+ .form .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;
+ letter-spacing:.06em;margin-bottom:3px}
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
@@ -102,7 +144,12 @@
-
Amber · stav služeb
+
Amber · stav
+
+
+
+
+
@@ -119,6 +166,12 @@ var KEY = "amber.status.token";
var tok = sessionStorage.getItem(KEY) || "";
var timer = null;
+// View + filters survive a refresh; the token deliberately does not.
+var PREF = "amber.status.prefs";
+var prefs = { view: "overview", days: 14, showTests: false };
+try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREF) || "{}")); } catch (_) {}
+function savePrefs(){ try { localStorage.setItem(PREF, JSON.stringify(prefs)); } catch (_) {} }
+
function el(id){ return document.getElementById(id); }
function esc(s){ return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
return ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]; }); }
@@ -150,6 +203,16 @@ async function api(path){
return r.json();
}
+// Collection reads go straight to the PocketBase REST API with the superuser
+// token the page already holds. No new server route: /api/status exists because
+// the Coolify and amber-api SECRETS must not reach the browser, which does not
+// apply to our own collections.
+async function records(coll, params){
+ var q = Object.keys(params).map(function(k){
+ return k + "=" + encodeURIComponent(params[k]); }).join("&");
+ return api("/api/collections/" + coll + "/records?" + q);
+}
+
function logout(){
sessionStorage.removeItem(KEY); tok = "";
if (timer) { clearInterval(timer); timer = null; }
@@ -173,6 +236,15 @@ el("loginBtn").onclick = async function(){
el("logout").onclick = logout;
el("refresh").onclick = function(){ load(); };
+Array.prototype.forEach.call(el("tabs").children, function(b){
+ b.onclick = function(){ prefs.view = b.getAttribute("data-view"); savePrefs(); syncTabs(); load(); };
+});
+function syncTabs(){
+ Array.prototype.forEach.call(el("tabs").children, function(b){
+ b.setAttribute("aria-current", b.getAttribute("data-view") === prefs.view ? "true" : "false");
+ });
+}
+
function panelServices(c){
if (!c || c.configured === false) {
return '
Buildy se nemusí shodovat mezi platformami — když se vydává jen ' +
+ "pro počítače, televize zůstane na starším čísle a nedostane výzvu k aktualizaci." +
+ "