amber-backend/pb_public/status.html
Claude 80c4bd498c The accounts table needed the whole width
Caught in a screenshot, invisible in the source: sharing a grid row with
the create form left the five-column table about half the page, so every
date broke as "2026-" / "08-05", the action button wrapped to two lines,
and a long address split mid-word. A wrapped date reads as two dates.

Full width, the address takes the slack, and the four columns that must
never wrap say so. Verified by counting line boxes per cell rather than
by eye — my first check compared cell HEIGHTS and flagged all five rows,
which was just the button's own height.
2026-08-08 23:33:22 +02:00

764 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Amber — stav služeb</title>
<style>
/* Amber's palette, matching the other pb_public pages. */
:root{
--bg:#0e0f13; --panel:#191b21; --panel2:#14161b; --line:#2a2d36;
--text:#f2e9d8; --muted:#9aa0aa; --accent:#f0a63c; --accent2:#c9791b;
--good:#5fd08a; --bad:#ff6b6b; --warn:#f0a63c; --goldbg:#1b1206;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
font-size:15px;line-height:1.5}
header{position:sticky;top:0;z-index:5;background:rgba(14,15,19,.94);
border-bottom:1px solid var(--line);padding:14px 16px;
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
h1{margin:0;font-size:17px;letter-spacing:.02em}
h1 span{color:var(--accent)}
.grow{flex:1}
.stamp{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
main{padding:16px;max-width:1100px;margin:0 auto;
display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
section{background:var(--panel);border:1px solid var(--line);border-radius:12px;
padding:14px 16px;min-width:0}
section.wide{grid-column:1/-1}
h2{margin:0 0 10px;font-size:11.5px;letter-spacing:.10em;text-transform:uppercase;
color:var(--muted);font-weight:600}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--panel2)}
th{color:var(--muted);font-weight:500;font-size:11.5px;text-transform:uppercase;
letter-spacing:.06em}
tr:last-child td{border-bottom:none}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
code,.mono{font-family:var(--mono);font-size:12.5px}
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11.5px;
font-weight:600;white-space:nowrap}
.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}
.kv .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em}
button{background:var(--panel2);color:var(--text);border:1px solid var(--line);
border-radius:8px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer}
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,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}
.login{max-width:340px;margin:14vh auto;padding:0 16px}
.login section{padding:18px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.svc{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--panel2)}
.svc:last-child{border-bottom:none}
.svc .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
.svc a:hover{color:var(--accent)}
.tag{color:var(--accent);font-size:11px;white-space:nowrap}
.chart{width:100%;height:150px;display:block;margin:4px 0 2px}
.chart .grid{stroke:var(--line);stroke-width:1}
.chart .tot{fill:var(--line)}
.chart .err{fill:var(--bad)}
.chart text{fill:var(--muted);font-size:9px;font-family:var(--mono)}
.legend{display:flex;gap:14px;font-size:11.5px;color:var(--muted);margin-top:2px}
.legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}
.sub{margin-top:14px}
.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}
/* The address takes the slack; nothing else may wrap. A wrapped date reads as
two dates, and a wrapped button reads as two buttons. */
.accts td:first-child{word-break:break-all}
.accts td:nth-child(2),.accts td:nth-child(3),.accts td:nth-child(4){white-space:nowrap}
.accts td:last-child{white-space:nowrap;text-align:right}
.accts button{white-space:nowrap}
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
</style>
</head>
<body>
<div id="login" class="login" hidden>
<section>
<h2>Amber — stav služeb</h2>
<p class="note" style="margin-bottom:12px">
Přihlaš se superuživatelem PocketBase. Tenhle přehled je jen pro správu.
</p>
<div style="display:grid;gap:8px">
<input id="email" type="email" placeholder="e-mail" autocomplete="username">
<input id="pass" type="password" placeholder="heslo" autocomplete="current-password">
<button class="primary" id="loginBtn">Přihlásit</button>
</div>
<p class="note" id="loginErr" style="color:var(--bad)"></p>
</section>
</div>
<div id="app" hidden>
<header>
<h1>Amber <span>·</span> stav</h1>
<div class="tabs" id="tabs">
<button data-view="overview">Přehled</button>
<button data-view="reports">Hlášení</button>
<button data-view="admin">Správa</button>
</div>
<div class="grow"></div>
<span class="stamp" id="stamp"></span>
<button id="refresh">Obnovit</button>
<button id="logout">Odhlásit</button>
</header>
<main id="main"></main>
</div>
<script>
// Superuser token lives in sessionStorage only: it dies with the tab, and this
// page is the one place it is used. The COOLIFY and amber-api secrets never come
// near the browser -- that is the whole reason /api/status exists server-side.
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 ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); }
function bytes(n){
if (!n) return "—";
var u = ["B","kB","MB","GB"], i = 0, v = Number(n);
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return v.toFixed(i ? 1 : 0) + " " + u[i];
}
// Coolify reports "running:healthy" / "running:unknown" / "exited:unhealthy".
// "unknown" means no healthcheck is configured, NOT that something is wrong --
// worth distinguishing, or every glance looks like a half-broken system.
function statusPill(s){
var t = String(s || "?");
if (t.indexOf("running") === 0) {
return t.indexOf("healthy") > 0
? '<span class="pill ok">běží</span>'
: '<span class="pill un" title="běží, ale bez healthchecku">běží</span>';
}
return '<span class="pill no">' + esc(t) + "</span>";
}
async function api(path){
var r = await fetch(path, { headers: { Authorization: tok } });
if (r.status === 401) { logout(); throw new Error("401"); }
if (!r.ok) throw new Error("HTTP " + r.status);
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; }
el("app").hidden = true; el("login").hidden = false;
}
el("loginBtn").onclick = async function(){
el("loginErr").textContent = "";
try {
var r = await fetch("/api/collections/_superusers/auth-with-password", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: el("email").value, password: el("pass").value }),
});
if (!r.ok) throw new Error("přihlášení selhalo");
tok = (await r.json()).token;
sessionStorage.setItem(KEY, tok);
el("pass").value = "";
start();
} catch (e) { el("loginErr").textContent = e.message; }
};
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 '<section><h2>Služby</h2><div class="warnbox">' + esc(c && c.why || "nenastaveno") +
"</div></section>";
}
if (c.error) {
return '<section><h2>Služby</h2><div class="warnbox">Coolify neodpovědělo: ' +
esc(c.error) + "</div></section>";
}
var rows = (c.apps || []).map(function(a){
return '<div class="svc">' + statusPill(a.status) +
'<span class="nm">' + esc(a.name) + "</span>" +
(a.note ? '<span class="tag">' + esc(a.note) + "</span>" : "") +
(a.fqdn ? '<a href="' + esc(a.fqdn.split(",")[0]) + '" target="_blank" rel="noopener">otevřít</a>' : "") +
'<button data-log="' + esc(a.uuid) + '">log</button></div>';
}).join("");
return '<section class="wide"><h2>Služby</h2>' + (rows || '<p class="note">nic</p>') +
'<div id="logbox"></div></section>';
}
function panelReleases(rs){
if (!rs || rs.error) return '<section><h2>Vydání</h2><p class="note">' +
esc(rs && rs.error || "—") + "</p></section>";
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
var body = rs.map(function(r){
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
"</td><td class='mono'>" + esc(r.version) + "+" + r.build +
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
}).join("");
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
body + "</table>" +
'<p class="note">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." +
"</p></section>";
}
function panelClients(c){
if (!c || c.error) return '<section><h2>Klienti</h2><p class="note">' +
esc(c && c.error || "—") + "</p></section>";
var ev = Object.keys(c.byEvent || {}).sort(function(a, b){ return c.byEvent[b] - c.byEvent[a]; })
.slice(0, 6).map(function(k){
return "<tr><td>" + esc(k) + "</td><td class='num'>" + c.byEvent[k] + "</td></tr>"; }).join("");
return '<section><h2>Klienti — 24 h</h2><div class="kv">' +
'<div><div class="lbl">záznamů</div><div class="big">' + c.total + "</div></div>" +
'<div><div class="lbl">chyb</div><div class="big" style="color:' +
(c.errors ? "var(--bad)" : "var(--good)") + '">' + c.errors + "</div></div>" +
'<div><div class="lbl">„sekalo se"</div><div class="big" style="color:' +
(c.stutterReports ? "var(--warn)" : "var(--muted)") + '">' + c.stutterReports + "</div></div>" +
"</div>" + (ev ? "<table style='margin-top:10px'>" + ev + "</table>" : "") +
'<p class="note">Hodnocení „sekalo se" při nulových stallech je slepé místo, ' +
"které počítadla nevidí — proto je zvlášť. Podrobnosti jsou v <b>Hlášení</b>." +
"</p></section>";
}
function panelApi(a){
if (!a || a.configured === false) {
return '<section><h2>amber-api</h2><div class="warnbox">' +
esc(a && a.why || "nenastaveno") + "</div></section>";
}
if (a.error) return '<section><h2>amber-api</h2><div class="warnbox">' +
esc(a.error) + "</div></section>";
var p = a.probe || {}, q = a.queue || {}, m = a.metadata || {}, h = a.health || {};
return '<section><h2>amber-api</h2><div class="kv">' +
'<div><div class="lbl">hotové sondy</div><div class="big">' + (p.ready || 0) + "</div></div>" +
'<div><div class="lbl">ve frontě</div><div class="big">' + (q.queued || 0) + "</div></div>" +
'<div><div class="lbl">selhalo</div><div class="big" style="color:' +
((p.failed || p.permafail) ? "var(--warn)" : "var(--muted)") + '">' +
((p.failed || 0) + (p.permafail || 0)) + "</div></div>" +
"</div><table style='margin-top:10px'>" +
"<tr><td>metadata (řádků / zásahů)</td><td class='num'>" + (m.rows || 0) + " / " + (m.hits || 0) + "</td></tr>" +
"<tr><td>zdraví zdrojů (klíčů)</td><td class='num'>" + (h.scoredKeys || 0) + "</td></tr>" +
"<tr><td>hostů v ochlazení</td><td class='num'>" + (q.hostsCoolingDown || 0) + "</td></tr>" +
"</table></section>";
}
function panelAccounts(a){
if (!a || a.error) return "";
return '<section><h2>Účty</h2><div class="kv">' +
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
'</div><p class="note">Nový účet se zakládá ve <b>Správě</b>.</p></section>';
}
// Errors over time. Inline SVG rather than a charting library: this page is served
// by PocketBase with no build step, and 14 bars do not justify a dependency.
function panelErrors(d){
if (!d || d.error) return '<section class="wide"><h2>Chyby v čase</h2>' +
'<div class="warnbox">' + esc(d && d.error || "—") + "</div></section>";
var days = d.days || [];
var max = 1;
days.forEach(function(x){ if (x.total > max) max = x.total; });
// viewBox coordinates, so the chart scales to any width without JS on resize.
var W = 700, H = 150, PAD = 18, bw = (W - PAD * 2) / Math.max(days.length, 1);
var bars = days.map(function(x, i){
var bx = PAD + i * bw + 1, bwv = Math.max(bw - 2, 1);
var th = Math.round((x.total / max) * (H - PAD * 2));
var eh = Math.round((x.errors / max) * (H - PAD * 2));
var t = esc(x.date) + ": " + x.total + " záznamů, " + x.errors + " chyb";
return '<g><title>' + t + "</title>" +
'<rect class="tot" x="' + bx + '" y="' + (H - PAD - th) + '" width="' + bwv +
'" height="' + th + '" rx="1"/>' +
(eh > 0 ? '<rect class="err" x="' + bx + '" y="' + (H - PAD - eh) + '" width="' + bwv +
'" height="' + eh + '" rx="1"/>' : "") + "</g>";
}).join("");
// Label only the ends and the middle; 14 dates do not fit on a phone.
var labels = days.length
? [0, Math.floor(days.length / 2), days.length - 1].map(function(i){
var x = PAD + i * bw + bw / 2;
var anchor = i === 0 ? "start" : (i === days.length - 1 ? "end" : "middle");
return '<text x="' + x + '" y="' + (H - 4) + '" text-anchor="' + anchor + '">' +
esc(days[i].date.substring(5)) + "</text>";
}).join("")
: "";
function tbl(title, rows, showRate){
if (!rows || !rows.length) return "";
var body = rows.map(function(r){
return "<tr><td>" + esc(r.key) + "</td><td class='num'>" + r.total +
"</td><td class='num' style='color:" + (r.errors ? "var(--bad)" : "var(--muted)") + "'>" +
r.errors + "</td>" +
(showRate ? "<td class='num rate' style='color:" +
(r.rate >= 20 ? "var(--bad)" : r.rate >= 5 ? "var(--warn)" : "var(--good)") +
"'>" + r.rate + " %</td>" : "") + "</tr>";
}).join("");
return '<div class="sub"><h3>' + esc(title) + "</h3><table>" +
"<tr><th></th><th class='num'>záznamů</th><th class='num'>chyb</th>" +
(showRate ? "<th class='num'>podíl</th>" : "") + "</tr>" + body + "</table></div>";
}
return '<section class="wide"><h2>Chyby v čase — ' + d.window + ' dní</h2>' +
'<svg class="chart" viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" ' +
'role="img" aria-label="chyby za posledních ' + d.window + ' dní">' +
'<line class="grid" x1="' + PAD + '" y1="' + (H - PAD) + '" x2="' + (W - PAD) +
'" y2="' + (H - PAD) + '"/>' + bars + labels + "</svg>" +
'<div class="legend"><span><i class="tot" style="background:var(--line)"></i>záznamy</span>' +
'<span><i style="background:var(--bad)"></i>chyby</span>' +
'<span style="margin-left:auto">vzorek ' + d.sampled +
(d.truncated ? " (zkráceno)" : "") + "</span></div>" +
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
tbl("Podle systému", d.byPlatform, true) +
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) +
'<p class="note">Historie je omezená retencí: client_logs se každou noc čistí ' +
"na 14 dní, starší data neexistují.</p></section>";
}
async function showLog(uuid, btn){
var box = el("logbox");
box.innerHTML = '<p class="note">načítám…</p>';
try {
var d = await api("/api/status/logs?app=" + encodeURIComponent(uuid) + "&lines=250");
var txt = (d.logs || "").trim() || "(prázdné)";
box.innerHTML = "<pre>" + esc(txt) + "</pre>";
box.querySelector("pre").scrollTop = box.querySelector("pre").scrollHeight;
} catch (e) {
box.innerHTML = '<div class="warnbox">log se nepodařilo načíst: ' + esc(e.message) + "</div>";
}
}
/* ══ 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 '<div class="f"><span class="k">' + esc(k) +
'</span><span class="v lo">neměří se</span></div>';
}
return '<div class="f"><span class="k">' + esc(k) + '</span><span class="v' +
(cls ? " " + cls : "") + '">' + esc(v) + "</span></div>";
}
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
? '<span class="pill wn">sekalo se</span>'
: (m.rating === "good" ? '<span class="pill ok">bylo dobré</span>' : "");
var head = '<div class="hd">' + verdict +
'<span class="who">' + esc(who(r)) + "</span>" +
'<span class="when">' + esc(String(r.created).substring(0, 16)) + "</span>" +
'<div class="grow"></div>' +
'<span class="box">' + esc(r.device || "?") + "</span>" +
'<span class="box">' + esc(r.appVersion || "?") +
(r.gitSha ? " · " + esc(r.gitSha) : "") + "</span>" +
(isTester(r) ? '<span class="pill un">náš test</span>' : "") + "</div>";
// "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 = '<div style="margin-bottom:8px"><b>' + esc(m.title || "?") + "</b> " +
'<span class="note" style="margin-left:8px">' +
"zhlédnuto " + (m.watchedS || 0) + " s" +
(m.durationS ? " z " + m.durationS + " s" : "") +
(reports ? " · tlačítko „hlásit“ v " + esc(JSON.stringify(reports)) + " s" : "") +
"</span></div>";
// 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 = '<div class="facts">' +
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) +
"</div>";
var newer = '<div class="facts" style="margin-top:8px">' +
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) +
"</div>";
var file = '<p class="note">' +
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(" · ")) + "</p>";
return '<div class="rep' + (isTester(r) ? " test" : "") + '">' + head + title +
counters + newer + file +
'<details class="raw"><summary>všechna pole</summary><pre>' +
esc(JSON.stringify(m, null, 1)) + "</pre></details></div>";
}
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 = '<div class="ctl">' +
'<label>období <select id="days">' +
[1, 3, 7, 14].map(function(d){
return '<option value="' + d + '"' + (prefs.days === d ? " selected" : "") +
">" + d + " dní</option>"; }).join("") + "</select></label>" +
'<label><input type="checkbox" id="showTests"' + (prefs.showTests ? " checked" : "") +
"> včetně našich testů</label>" +
'<div class="grow"></div><span class="note" style="margin:0">' +
shown.length + " z " + reps.length + " hlášení</span></div>";
var body = shown.length
? shown.map(reportCard).join("")
: '<div class="okbox">Za tohle období si nikdo nestěžoval.</div>';
return '<section class="wide"><h2>Hlášení — co hlásila rodina</h2>' + ctl + body +
'<p class="note">„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.</p></section>";
}
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 "<tr><td class='mono'>" + esc(k) +
(g.tester ? ' <span class="pill un">náš test</span>' : "") + "</td>" +
"<td>" + esc(Object.keys(g.who).join(", ")) + "</td>" +
"<td class='mono'>" + esc(Object.keys(g.ver).join(", ")) + "</td>" +
"<td class='num'>" + g.n + "</td>" +
"<td class='mono'>" + esc(g.last.substring(0, 16)) + "</td></tr>";
}).join("");
return '<section class="wide"><h2>Zařízení — kde se kouká</h2><table>' +
"<tr><th>krabice</th><th>kdo</th><th>build</th><th class='num'>sezení</th>" +
"<th>naposled</th></tr>" + body + "</table>" +
'<p class="note">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í.</p></section>";
}
/* ══ 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 "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
"<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>'
: '<span class="pill ok">clean</span>') + "</td>" +
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
'<td><button data-nsfw="' + esc(u.id) + '" data-to="' +
(u.nsfwEnabled ? "0" : "1") + '">' +
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
}).join("");
return '<section><h2>Nový účet</h2>' +
'<div class="form">' +
'<div><div class="lbl">e-mail</div><input id="nEmail" type="email" ' +
'autocomplete="off" placeholder="jmeno@example.com"></div>' +
'<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' +
'<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
'placeholder="nejméně 8 znaků"></div>' +
'<label class="row" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
"povolit 18+ (jinak dostane clean build)</label>" +
'<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' +
'<option value="">bez omezení</option><option value="kids">kids</option>' +
'<option value="twelve">12+</option><option value="fifteen">15+</option>' +
"</select></div>" +
'<button class="primary" id="createBtn">Založit účet</button>' +
"</div>" +
'<div id="createOut"></div>' +
'<p class="note">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í.</p>" +
'<p class="note">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ý.</p></section>" +
'<section class="wide"><h2>Účty</h2><table class="accts">' +
"<tr><th>e-mail</th><th>varianta</th><th>strop</th><th>vznik</th><th></th></tr>" +
(rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" +
'<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' +
"aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " +
"vyžádat.</p></section>";
}
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 = '<div class="warnbox">E-mail a heslo (min. 8 znaků) jsou povinné.</div>';
return;
}
out.innerHTML = '<p class="note">zakládám…</p>';
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 = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
". Předej heslo, tady už ho znovu neuvidíš.</div>";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = "";
load();
} catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
}
}
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",
'<section class="wide"><div class="warnbox">' + esc(e.message) + "</div></section>");
}
}
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("main").innerHTML =
panelServices(d.coolify) + panelReleases(d.releases) +
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) +
panelErrors(errs);
wire();
} catch (e) {
if (e.message !== "401") {
el("main").innerHTML = '<section class="wide"><div class="warnbox">' +
esc(e.message) + "</div></section>";
}
}
}
function start(){
el("login").hidden = true; el("app").hidden = false;
syncTabs();
load();
if (timer) clearInterval(timer);
// 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;
</script>
</body>
</html>