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;