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 @@ " + "" + (ev ? "" + ev + "
" : "") + '

Hodnocení „sekalo se" při nulových stallech je slepé místo, ' + - "které počítadla nevidí — proto je zvlášť.

"; + "které počítadla nevidí — proto je zvlášť. Podrobnosti jsou v Hlášení." + + "

"; } function panelApi(a){ @@ -251,7 +327,7 @@ function panelAccounts(a){ '
celkem
' + a.total + "
" + '
clean
' + a.clean + "
" + '
18+
' + a.adult + "
" + - ""; + '

Nový účet se zakládá ve Správě.

'; } // Errors over time. Inline SVG rather than a charting library: this page is served @@ -301,9 +377,6 @@ function panelErrors(d){ (showRate ? "podíl" : "") + "" + body + ""; } - var flav = (d.byFlavour || []); - var flavKnown = flav.some(function(f){ return f.key !== "neznámá"; }); - return '

Chyby v čase — ' + d.window + ' dní

' + '' + @@ -315,10 +388,7 @@ function panelErrors(d){ (d.truncated ? " (zkráceno)" : "") + "" + tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) + tbl("Podle systému", d.byPlatform, true) + - tbl("Podle varianty", flav, true) + - (flavKnown ? "" : '

Varianta (clean/18+) se zatím do diagnostiky ' + - "neposílá — appVersion a platform ano. Až ji aplikace začne posílat, tenhle " + - "řádek se naplní sám, backend se měnit nemusí.

") + + tbl("Podle varianty", d.byFlavour, true) + tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){ return { key: x.key, total: x.errors, errors: x.errors }; }), false) + '

Historie je omezená retencí: client_logs se každou noc čistí ' + @@ -338,19 +408,332 @@ async function showLog(uuid, btn){ } } +/* ══ Hlášení ═══════════════════════════════════════════════════════════════════ + One incident per card, with the counters that were clean beside the ones that + were not. This exists because a real report used to arrive as + `user: 1ve1qbjb...` on `device: android`, which says neither who is unhappy nor + which of the four televisions -- and could not be told apart from our own test + runs at all. + + `tester` marks our sessions and they are HIDDEN by default: the question this + view answers is what the family experienced. */ + +function isTester(r){ + if (r.tester === true) return true; + // Rows written before the `tester` column existed, and any future client that + // omits it, fall back to the emulator tell. Absent-and-old counts as the + // family's, which over-reports rather than hiding a real complaint. + return String(r.device || "").indexOf("(emulator)") >= 0; +} + +function who(r){ + var u = (r.expand && r.expand.user) || null; + return u ? (u.email || u.username || u.id) : (r.user ? r.user : "anonym"); +} + +// A fact is dimmed when it carries no information, highlighted when it does. The +// judgement is in one place so a card can be read at a glance instead of parsed. +function fact(k, v, cls){ + if (v === undefined || v === null || v === "") { + return '

' + esc(k) + + 'neměří se
'; + } + return '
' + esc(k) + '' + esc(v) + "
"; +} + +function reportCard(r){ + var m = r.meta || {}; + var stutter = m.rating === "stutter"; + var reports = m.problemAtS; + var mean = m.frameOffsetCount + ? Math.round(m.frameOffsetUsTotal / m.frameOffsetCount / 1000) + " ms" : null; + + var verdict = stutter + ? 'sekalo se' + : (m.rating === "good" ? 'bylo dobré' : ""); + var head = '
' + verdict + + '' + esc(who(r)) + "" + + '' + esc(String(r.created).substring(0, 16)) + "" + + '
' + + '' + esc(r.device || "?") + "" + + '' + esc(r.appVersion || "?") + + (r.gitSha ? " · " + esc(r.gitSha) : "") + "" + + (isTester(r) ? 'náš test' : "") + "
"; + + // "zhlédnuto", and a space before the note: rendered output read + // "Pravá a levá ruka ďáblashlédnuto 1278 s", which is the kind of thing that is + // invisible in the source and obvious in one frame. + var title = '
' + esc(m.title || "?") + " " + + '' + + "zhlédnuto " + (m.watchedS || 0) + " s" + + (m.durationS ? " z " + m.durationS + " s" : "") + + (reports ? " · tlačítko „hlásit“ v " + esc(JSON.stringify(reports)) + " s" : "") + + "
"; + + // Grouped by the question each answers, because the whole point is that the + // first group can be clean while the viewer saw the picture hitch. + var counters = '
' + + fact("stally (buffering)", m.stalls, m.stalls > 0 ? "hi" : null) + + fact("zahozené snímky", m.droppedFrames, m.droppedFrames > 0 ? "hi" : null) + + fact("nejdelší série", m.maxConsecutiveDropped, m.maxConsecutiveDropped > 0 ? "hi" : null) + + fact("odchylka snímků", mean) + + "
"; + + var newer = '
' + + fact("blokující GC", m.gcBlockingCount === undefined ? undefined + : m.gcBlockingCount + "× / " + (m.gcBlockingMs || 0) + " ms", + m.gcBlockingCount > 0 ? "hi" : null) + + fact("chyby načtení", m.loadErrors === undefined ? undefined + : m.loadErrors + (m.lastLoadError ? " (" + m.lastLoadError + ")" : ""), + m.loadErrors > 0 ? "bad" : null) + + fact("přenos", m.throughputKbps > 0 + ? Math.round(m.throughputKbps / 1000) + " Mb/s (min " + + Math.round((m.throughputMinKbps || 0) / 1000) + ")" : undefined) + + fact("buffer nejméně", m.demuxerCacheMinS === undefined ? undefined + : m.demuxerCacheMinS + " s", m.demuxerCacheMinS < 3 ? "hi" : null) + + fact("snímků/s", m.fpsRendered) + + fact("panel", m.displayRefreshHz ? m.displayRefreshHz + " Hz" : undefined) + + fact("dekodér", m.videoDecoder) + + fact("paměť", m.heapMaxMb ? m.heapUsedMb + " / " + m.heapMaxMb + " MB" : undefined) + + "
"; + + var file = '

' + + esc([m.videoHeight ? m.videoHeight + "p" : null, + (m.videoCodec || "").replace("video/", ""), + (m.audioCodec || "").replace("audio/", ""), + m.audioChannels ? m.audioChannels + "ch" : null, + m.audioLang, + m.forcedAudio ? "vynucený dabing" : null, + m.host ? String(m.host).replace(/^https?:\/\//, "") : null, + ].filter(Boolean).join(" · ")) + "

"; + + return '
' + head + title + + counters + newer + file + + '
všechna pole
' +
+    esc(JSON.stringify(m, null, 1)) + "
"; +} + +function viewReports(rows){ + var reps = rows.filter(function(r){ + var m = r.meta || {}; + return m.rating === "stutter" || (m.problemAtS && m.problemAtS.length); + }); + var shown = prefs.showTests ? reps : reps.filter(function(r){ return !isTester(r); }); + + var ctl = '
' + + '" + + '" + + '
' + + shown.length + " z " + reps.length + " hlášení
"; + + var body = shown.length + ? shown.map(reportCard).join("") + : '
Za tohle období si nikdo nestěžoval.
'; + + return '

Hlášení — co hlásila rodina

' + ctl + body + + '

„Neměří se“ znamená, že to tenhle přehrávač neposílá — ' + + "televize (ExoPlayer) posílá všechno, počítač (mpv) jen část. Je to jiné " + + "tvrzení než nula.

"; +} + +function viewDevices(rows){ + // Group by box, because "which television" was unanswerable until the device + // label carried a model and a per-install id. + var by = {}; + rows.forEach(function(r){ + var k = r.device || "?"; + if (!by[k]) by[k] = { n: 0, tester: isTester(r), who: {}, ver: {}, last: "" }; + var g = by[k]; + g.n++; + g.who[who(r)] = true; + g.ver[(r.appVersion || "?") + (r.gitSha ? " · " + r.gitSha : "")] = true; + if (r.created > g.last) g.last = r.created; + }); + var keys = Object.keys(by).sort(function(a, b){ return by[b].n - by[a].n; }); + if (!keys.length) return ""; + var body = keys.map(function(k){ + var g = by[k]; + return "" + esc(k) + + (g.tester ? ' náš test' : "") + "" + + "" + esc(Object.keys(g.who).join(", ")) + "" + + "" + esc(Object.keys(g.ver).join(", ")) + "" + + "" + g.n + "" + + "" + esc(g.last.substring(0, 16)) + ""; + }).join(""); + return '

Zařízení — kde se kouká

' + + "" + + "" + body + "
krabicekdobuildsezenínaposled
" + + '

Identifikátor za tečkou je náhodný a jen místní — říká ' + + "„je to pořád ta samá krabice“, nic víc. Po reinstalaci se změní.

"; +} + +/* ══ Správa ════════════════════════════════════════════════════════════════════ + Creating an account is a plain POST to the users collection with the superuser + token this page already holds. Registration stays CLOSED -- there is no public + signup and no invite code behind this; `users.createRule` is null, so only a + superuser can do it, which is exactly who is looking at this screen. */ + +function viewAdmin(users){ + var rows = (users || []).map(function(u){ + return "" + esc(u.email || u.username || u.id) + "" + + "" + (u.nsfwEnabled ? '18+' + : 'clean') + "" + + "" + esc(u.ratingDefault || "—") + "" + + "" + esc(String(u.created).substring(0, 10)) + "" + + '"; + }).join(""); + + return '

Nový účet

' + + '
' + + '
e-mail
' + + '
jméno (nepovinné)
' + + '
heslo
' + + '" + + '
věkový strop
" + + '' + + "
" + + '
' + + '

Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' + + "musíš mu ho předat. Nikam se neukládá, po založení zmizí.

" + + '

Registrace je zavřená: veřejné zakládání účtů neexistuje a ' + + "tohle jde přes superuživatele, kterým jsi právě přihlášený.

" + + + '

Účty

' + + "" + + (rows || "") + "
e-mailvariantastropvznik
nic
" + + '

Varianta rozhoduje, jaký build dostane přes automatickou ' + + "aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " + + "vyžádat.

"; +} + +async function createAccount(){ + var out = el("createOut"); + var email = el("nEmail").value.trim(); + var pass = el("nPass").value; + if (!email || pass.length < 8) { + out.innerHTML = '
E-mail a heslo (min. 8 znaků) jsou povinné.
'; + return; + } + out.innerHTML = '

zakládám…

'; + try { + var body = { + email: email, password: pass, passwordConfirm: pass, + nsfwEnabled: el("nNsfw").checked, + // Verified up front: the family never goes through an e-mail confirmation, + // and an unverified account would just be a trap nobody could clear. + verified: true, + }; + var name = el("nName").value.trim(); + if (name) body.name = name; + var rating = el("nRating").value; + if (rating) body.ratingDefault = rating; + + var r = await fetch("/api/collections/users/records", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify(body), + }); + var d = await r.json(); + if (!r.ok) { + // PocketBase nests the useful part per field; a bare "400" tells nobody + // whether the address is taken or the password is too short. + var why = d && d.data + ? Object.keys(d.data).map(function(k){ + return k + ": " + (d.data[k].message || "?"); }).join("; ") + : (d && d.message) || ("HTTP " + r.status); + throw new Error(why); + } + out.innerHTML = '
Hotovo. Účet ' + esc(email) + + " je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") + + ". Předej heslo, tady už ho znovu neuvidíš.
"; + el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = ""; + el("nNsfw").checked = false; el("nRating").value = ""; + load(); + } catch (e) { + out.innerHTML = '
nepodařilo se: ' + esc(e.message) + "
"; + } +} + +async function setNsfw(id, to){ + try { + var r = await fetch("/api/collections/users/records/" + encodeURIComponent(id), { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify({ nsfwEnabled: to === "1" }), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + load(); + } catch (e) { + el("main").insertAdjacentHTML("afterbegin", + '
' + esc(e.message) + "
"); + } +} + +function wire(){ + Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){ + b.onclick = function(){ showLog(b.getAttribute("data-log"), b); }; + }); + Array.prototype.forEach.call(el("main").querySelectorAll("button[data-nsfw]"), function(b){ + b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); }; + }); + if (el("createBtn")) el("createBtn").onclick = createAccount; + if (el("days")) el("days").onchange = function(){ + prefs.days = Number(el("days").value); savePrefs(); load(); }; + if (el("showTests")) el("showTests").onchange = function(){ + prefs.showTests = el("showTests").checked; savePrefs(); load(); }; +} + +// Sessions for the reports + devices views. `expand=user` resolves the reporter's +// address here rather than the app writing identity onto 600 telemetry rows. +async function sessions(){ + var from = new Date(Date.now() - prefs.days * 864e5).toISOString() + .replace("T", " ").substring(0, 19); + var d = await records("client_logs", { + filter: "event='session_summary' && created>='" + from + "'", + sort: "-created", perPage: 400, expand: "user", + }); + return d.items || []; +} + async function load(){ try { + el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ"); + + if (prefs.view === "admin") { + var us = await records("users", { sort: "-created", perPage: 200 }); + el("main").innerHTML = viewAdmin(us.items || []); + wire(); + return; + } + if (prefs.view === "reports") { + var rows = await sessions(); + el("main").innerHTML = viewReports(rows) + viewDevices(rows); + wire(); + return; + } + var d = await api("/api/status"); var errs = null; try { errs = await api("/api/status/errors"); } catch (_) { errs = { error: "nedostupné" }; } - el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ"); el("main").innerHTML = panelServices(d.coolify) + panelReleases(d.releases) + panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) + panelErrors(errs); - Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){ - b.onclick = function(){ showLog(b.getAttribute("data-log"), b); }; - }); + wire(); } catch (e) { if (e.message !== "401") { el("main").innerHTML = '
' + @@ -361,9 +744,12 @@ async function load(){ function start(){ el("login").hidden = true; el("app").hidden = false; + syncTabs(); load(); if (timer) clearInterval(timer); - timer = setInterval(load, 60000); // a minute is plenty; this is not a metrics system + // A minute is plenty; this is not a metrics system. The admin view is excluded + // because a refresh under a half-typed form would wipe it. + timer = setInterval(function(){ if (prefs.view !== "admin") load(); }, 60000); } if (tok) start(); else el("login").hidden = false;