Asked for because a family member could not get a reset email. The cause turned out not to be mail at all — PocketBase looks accounts up case-sensitively, he was typing a capitalised address, and the endpoint answers 204 either way so that nobody can use it to discover which addresses exist. The app now lower-cases addresses, which should stop it recurring. This stays for the case that fixes: somebody who has genuinely forgotten a password and cannot receive mail. One field and one button per row of a table only a superuser can load, so it grants no capability the page did not already have — the same token already creates accounts. The field is cleared the moment it succeeds. It exists to be typed and read aloud once, and a password left sitting in an input is one that ends up in a screenshot. Verified against the live backend on a throwaway account: created it, signed in with the first password, set a second from this path, signed in with the second, was refused the first, deleted it.
1163 lines
56 KiB
HTML
1163 lines
56 KiB
HTML
<!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}
|
||
.accts input.pw{width:190px;margin-right:6px}
|
||
.accts td:last-child{white-space:nowrap}
|
||
.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}
|
||
/* Three across on a wide screen, one on a phone. A 420px column inside a
|
||
full-width panel left two thirds of the row empty. */
|
||
.form{display:grid;gap:10px 18px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
||
.form .span{grid-column:1/-1}
|
||
.form button{justify-self:start;padding:9px 18px}
|
||
.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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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>";
|
||
}
|
||
|
||
// Takes the options its callers have always passed.
|
||
//
|
||
// It used to accept only `path`, so every write on this page -- publishing a
|
||
// notice, switching one off -- was quietly performed as a GET. The read
|
||
// succeeded (the collection is publicly listable), so the page reported
|
||
// "zveřejněno" and nothing had been written. A notice about the anime tab being
|
||
// down never reached anyone.
|
||
async function api(path, opts){
|
||
opts = opts || {};
|
||
var init = { method: opts.method || "GET", headers: { Authorization: tok } };
|
||
if (opts.body !== undefined) {
|
||
init.headers["Content-Type"] = "application/json";
|
||
init.body = JSON.stringify(opts.body);
|
||
}
|
||
var r = await fetch(path, init);
|
||
if (r.status === 401) { logout(); throw new Error("401"); }
|
||
if (!r.ok) {
|
||
// PocketBase explains itself in the body; say what it said rather than a
|
||
// bare status, or a permission problem looks the same as an outage.
|
||
var detail = "";
|
||
try {
|
||
var j = await r.json();
|
||
if (j && j.message) detail = ": " + j.message;
|
||
} catch (_) {}
|
||
throw new Error("HTTP " + r.status + detail);
|
||
}
|
||
if (r.status === 204) return null;
|
||
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);
|
||
}
|
||
|
||
/* Every group, for the picker and for the create form's dropdown. Failure is an
|
||
empty list rather than a throw: a dashboard that renders nothing because one
|
||
collection is missing is worse than one that says there are no groups yet. */
|
||
async function allTemplates(){
|
||
try {
|
||
var r = await records("onboarding_template", { perPage: 50, sort: "name" });
|
||
return r.items || [];
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
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 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>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><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("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. */
|
||
|
||
|
||
/* A message the family sees in the app, for breakage we cannot fix.
|
||
*
|
||
* Lives here rather than only in the PocketBase admin UI because the moment it
|
||
* is needed is the moment something is already going wrong, and a form that
|
||
* takes one sentence and a button beats remembering a collection name and a
|
||
* rule syntax under pressure.
|
||
*
|
||
* The warning is not decoration. The collection is PUBLICLY readable so a notice
|
||
* still appears when signing in is itself what is broken, which means anything
|
||
* typed here is world-readable to whoever finds the backend. */
|
||
function viewNotices(ns){
|
||
var rows = (ns || []).map(function(n){
|
||
var ends = n.endsAt ? String(n.endsAt).substring(0, 16) : "—";
|
||
return "<tr><td>" + esc(n.message) + "</td>" +
|
||
"<td>" + (n.active ? '<span class="pill wn">viditelné</span>'
|
||
: '<span class="pill ok">skryté</span>') + "</td>" +
|
||
"<td class='mono'>" + esc(ends) + "</td>" +
|
||
'<td><button data-notice="' + esc(n.id) + '" data-to="' +
|
||
(n.active ? "0" : "1") + '">' +
|
||
(n.active ? "skrýt" : "zobrazit") + "</button></td></tr>";
|
||
}).join("");
|
||
|
||
return '<section class="wide"><h2>Oznámení v aplikaci</h2>' +
|
||
'<p class="note">Uvidí ho každý v aplikaci, i nepřihlášený. ' +
|
||
'Nepiš sem nic citlivého — jména služeb, adresy ani klíče.</p>' +
|
||
'<div class="kv"><label>Text<input id="nMsg" maxlength="400" ' +
|
||
'placeholder="Anime dnes nefunguje, je to výpadek u poskytovatele."></label>' +
|
||
'<label>Skrýt po (nepovinné)<input id="nEnds" type="datetime-local"></label></div>' +
|
||
'<button id="nSave">Zveřejnit</button> <span id="nMsgOut" class="note"></span>' +
|
||
(rows ? '<table><tr><th>Text</th><th>Stav</th><th>Do</th><th></th></tr>' +
|
||
rows + '</table>' : '<p class="note">zatím žádné</p>') +
|
||
'</section>';
|
||
}
|
||
|
||
async function saveNotice(){
|
||
var msg = (el("nMsg").value || "").trim();
|
||
var out = el("nMsgOut");
|
||
if (!msg) { out.textContent = "napiš text"; return; }
|
||
var body = { message: msg, active: true };
|
||
var ends = el("nEnds").value;
|
||
if (ends) body.endsAt = new Date(ends).toISOString().replace("T", " ").substring(0, 19) + "Z";
|
||
try {
|
||
await api("/api/collections/notices/records", { method: "POST", body: body });
|
||
out.textContent = "zveřejněno";
|
||
await load();
|
||
} catch (e) { out.textContent = "nepovedlo se: " + e.message; }
|
||
}
|
||
|
||
async function setNoticeActive(id, to){
|
||
try {
|
||
await api("/api/collections/notices/records/" + id,
|
||
{ method: "PATCH", body: { active: to === "1" } });
|
||
await load();
|
||
} catch (e) { alert("nepovedlo se: " + e.message); }
|
||
}
|
||
|
||
/**
|
||
* Set someone's password directly.
|
||
*
|
||
* Exists because the emailed reset cannot be relied on, and the reason is not
|
||
* the mail server: PocketBase looks accounts up **case-sensitively**, so a
|
||
* family member typing `Vojta.Markup@seznam.cz` for an account stored in
|
||
* lowercase matches nothing — and the endpoint answers 204 either way, so that
|
||
* nobody can use it to discover which addresses exist. From the sofa that is
|
||
* indistinguishable from mail being broken, which is how it was reported.
|
||
*
|
||
* The app now lower-cases addresses before sending them, so this should stop
|
||
* happening. This stays for the case it cannot fix: somebody who has genuinely
|
||
* forgotten a password and cannot receive mail.
|
||
*
|
||
* No confirmation prompt. The button is one row of a table only a superuser can
|
||
* load, the field says what it does, and an accidental press with an empty box
|
||
* does nothing.
|
||
*/
|
||
async function setPassword(id, btn){
|
||
var input = el("pw_" + id);
|
||
var out = el("pwout_" + id);
|
||
var pw = (input.value || "").trim();
|
||
out.style.color = "";
|
||
if (pw.length < 8) { out.textContent = "nejméně 8 znaků"; return; }
|
||
btn.disabled = true;
|
||
out.textContent = "nastavuji…";
|
||
try {
|
||
// A superuser may set a password without the old one. `passwordConfirm` is
|
||
// required by the collection rules even so.
|
||
await api("/api/collections/users/records/" + id,
|
||
{ method: "PATCH", body: { password: pw, passwordConfirm: pw } });
|
||
// Cleared immediately: it was only ever here to be typed and handed over,
|
||
// and a password left sitting in a field is one that ends up in a
|
||
// screenshot.
|
||
input.value = "";
|
||
out.textContent = "hotovo — předej mu ho";
|
||
} catch (e) {
|
||
out.style.color = "var(--bad)";
|
||
out.textContent = "nepovedlo se: " + e.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function viewAdmin(users, tpls){
|
||
var rows = (users || []).map(function(u){
|
||
var id = esc(u.id);
|
||
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
|
||
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
|
||
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
|
||
"<td><input class='pw' id='pw_" + id + "' type='text' autocomplete='off' " +
|
||
"placeholder='nové heslo, nejméně 8 znaků'>" +
|
||
"<button data-pw='" + id + "'>Nastavit</button>" +
|
||
"<span class='note' id='pwout_" + id + "'></span></td></tr>";
|
||
}).join("");
|
||
|
||
return '<section class="wide"><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>' +
|
||
'<div class="span"><div class="lbl">sdílené nastavení</div>' +
|
||
'<select id="nGroup" style="width:100%">' +
|
||
'<option value="">žádné — zdroje si zadá sám</option>' +
|
||
(tpls || []).map(function(t){
|
||
return '<option value="' + esc(t.id) + '"' + (t.isDefault ? " selected" : "") +
|
||
">" + esc(t.name || "(bez názvu)") + "</option>"; }).join("") +
|
||
"</select>" +
|
||
'<p class="note">Zdroje i TMDB klíč nastavím hned a nemusí zadávat kód. ' +
|
||
"Kdo si nese vlastní účty, dostane „žádné“.</p></div>" +
|
||
'<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>" +
|
||
'<div class="span"><button class="primary" id="createBtn">Založit účet</button></div>' +
|
||
"</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>strop</th><th>vznik</th><th>heslo</th></tr>" +
|
||
(rows || "<tr><td colspan=4 class='note'>nic</td></tr>") + "</table>" +
|
||
'<p class="note">Heslo nastavíš rovnou, bez e-mailu — reset přes e-mail ' +
|
||
"selhává tiše, když si adresu někdo napíše s velkým písmenem, protože " +
|
||
"PocketBase hledá účty přesně na znak a stejně vždycky odpoví „odesláno“, " +
|
||
"aby přes něj nešlo zjišťovat, které adresy existují.</p>" +
|
||
'<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>";
|
||
}
|
||
|
||
/* ── Sdílená nastavení (onboarding_template) ──────────────────────────────────
|
||
The shared config a new member's first setup pulls down, unlocked by the code of
|
||
their group. One row per group the owner shares accounts with, so the family and
|
||
friends never land on each other's subscriptions; superuser-only on every rule,
|
||
so the dashboard reads and writes them directly.
|
||
|
||
The picker below is the only place these are edited. It used to edit "the"
|
||
template, chosen by recency, which is the bug that made a second group unsafe --
|
||
see pb_migrations/1791000000_onboarding_template_codes.js.
|
||
|
||
These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login
|
||
and a TorBox key. They are masked until you ask to see them, and nothing here
|
||
logs or echoes them. That is also why this lives behind a superuser login on a
|
||
noindex page and nowhere else. */
|
||
|
||
/* Which group the editor is pointed at: a record id, or "new". Held here rather
|
||
than read off the <select> because `load()` re-renders the section from scratch
|
||
and the choice has to survive that. */
|
||
var tplSel = null;
|
||
|
||
function viewTemplate(list){
|
||
var rows = list || [];
|
||
var sel = tplSel ? rows.filter(function(r){ return r.id === tplSel; })[0] : null;
|
||
if (!sel && tplSel !== "new") {
|
||
// Default to the family row, so opening the page lands where it always did.
|
||
sel = rows.filter(function(r){ return r.isDefault; })[0] || rows[0] || null;
|
||
tplSel = sel ? sel.id : "new";
|
||
}
|
||
var v = sel || {};
|
||
var opts = rows.map(function(r){
|
||
return '<option value="' + esc(r.id) + '"' +
|
||
(sel && r.id === sel.id ? " selected" : "") + ">" +
|
||
esc(r.name || "(bez názvu)") + (r.isDefault ? " (výchozí)" : "") + "</option>";
|
||
}).join("") + '<option value="new"' + (tplSel === "new" ? " selected" : "") +
|
||
">+ nová skupina</option>";
|
||
return '<section class="wide"><h2>Sdílené nastavení — co dostane nový člen</h2>' +
|
||
'<div class="form">' +
|
||
'<div class="span"><div class="lbl">skupina</div>' +
|
||
'<select id="tGroup" style="width:100%">' + opts + "</select></div>" +
|
||
'<div><div class="lbl">název skupiny</div><input id="tName" ' +
|
||
'autocomplete="off" placeholder="rodina, přátelé…" value="' +
|
||
esc(v.name || "") + '"></div>' +
|
||
'<div><div class="lbl">kód téhle skupiny</div><input id="tCode" ' +
|
||
'type="password" autocomplete="off" value="' + esc(v.code || "") + '"></div>' +
|
||
'<div class="span"><div class="lbl">TorBox doplněk (addonUrl)</div>' +
|
||
'<input id="tAddon" type="password" autocomplete="off" value="' +
|
||
esc(v.addonUrl || "") + '"></div>' +
|
||
'<div class="span"><div class="lbl">Český doplněk (czechAddonUrl)</div>' +
|
||
'<input id="tCzech" type="password" autocomplete="off" value="' +
|
||
esc(v.czechAddonUrl || "") + '"></div>' +
|
||
'<div><div class="lbl">TMDB klíč</div><input id="tTmdb" type="password" ' +
|
||
'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' +
|
||
'<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' +
|
||
'autocomplete="off" value="' + esc(v.note || "") + '"></div>' +
|
||
'<label class="row span" style="color:var(--muted);font-size:13px">' +
|
||
'<input type="checkbox" id="tDefault"' + (v.isDefault ? " checked" : "") +
|
||
' style="width:auto;accent-color:var(--accent)"> ' +
|
||
"výchozí skupina — tuhle otevře starý kód z AMBER_INVITE_CODE</label>" +
|
||
'<label class="row span" style="color:var(--muted);font-size:13px">' +
|
||
'<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' +
|
||
"zobrazit hodnoty</label>" +
|
||
'<div class="span"><button class="primary" id="tSave">Uložit nastavení</button>' +
|
||
(v.updated ? '<span class="note" style="margin-left:12px">naposledy ' +
|
||
esc(String(v.updated).substring(0, 16)) + "</span>" : "") + "</div>" +
|
||
"</div><div id=\"tOut\"></div>" +
|
||
'<p class="note">V těchhle adresách jsou přihlašovací údaje k prehraj.to a ' +
|
||
"klíč k TorBoxu — proto jsou schované a proto je tahle stránka jen pro tebe. " +
|
||
"Kdo dostane kód téhle skupiny, stáhne si přesně tyhle zdroje.</p>" +
|
||
'<p class="note">Každá skupina má vlastní údaje a vlastní kód, takže přátelé ' +
|
||
"nesdílí účty s rodinou. Kód musí být unikátní; dvě skupiny se stejným kódem " +
|
||
"databáze odmítne.</p>" +
|
||
(rows.length ? "" : '<div class="warnbox">Žádná skupina ještě není uložená — ' +
|
||
"první uložení ji vytvoří.</div>") + "</section>";
|
||
}
|
||
|
||
async function saveTemplate(){
|
||
var out = el("tOut");
|
||
var name = el("tName").value.trim();
|
||
if (!name) {
|
||
out.innerHTML = '<div class="warnbox">Skupina musí mít název — jinak ji ' +
|
||
"v seznamu nerozeznáš od ostatních.</div>";
|
||
return;
|
||
}
|
||
out.innerHTML = '<p class="note">ukládám…</p>';
|
||
var body = {
|
||
name: name,
|
||
code: el("tCode").value.trim(),
|
||
isDefault: el("tDefault").checked,
|
||
addonUrl: el("tAddon").value.trim(),
|
||
czechAddonUrl: el("tCzech").value.trim(),
|
||
tmdbKey: el("tTmdb").value.trim(),
|
||
note: el("tNote").value.trim(),
|
||
};
|
||
try {
|
||
var creating = (!tplSel || tplSel === "new");
|
||
var r = await fetch("/api/collections/onboarding_template/records" +
|
||
(creating ? "" : "/" + tplSel), {
|
||
method: creating ? "POST" : "PATCH",
|
||
headers: { "Content-Type": "application/json", Authorization: tok },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) {
|
||
var d = await r.json();
|
||
// Per-field, because the one that actually happens here is a duplicate code
|
||
// hitting the unique index, and a bare "400" would not say so.
|
||
var why = d && d.data && Object.keys(d.data).length
|
||
? Object.keys(d.data).map(function(k){
|
||
return k + ": " + (d.data[k].message || "?"); }).join("; ")
|
||
: (d && d.message) || ("HTTP " + r.status);
|
||
throw new Error(why);
|
||
}
|
||
var saved = await r.json();
|
||
tplSel = saved.id;
|
||
|
||
// **Exactly one default.** The route's fallback for the old AMBER_INVITE_CODE
|
||
// takes "the" isDefault row, so two of them would
|
||
// put us straight back to the bug this whole change fixed: which credentials the
|
||
// old code hands out would depend on edit order. Ticking the box therefore
|
||
// unticks it everywhere else rather than being quietly ignored.
|
||
var moved = "";
|
||
if (body.isDefault) {
|
||
var all = await allTemplates();
|
||
for (var i = 0; i < all.length; i++) {
|
||
if (all[i].id !== saved.id && all[i].isDefault) {
|
||
await fetch("/api/collections/onboarding_template/records/" + all[i].id, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json", Authorization: tok },
|
||
body: JSON.stringify({ isDefault: false }),
|
||
});
|
||
moved = " Výchozí skupina je teď „" + esc(name) + "“, ne „" +
|
||
esc(all[i].name || "(bez názvu)") + "“.";
|
||
}
|
||
}
|
||
}
|
||
// `load()` replaces the whole section, so writing the message first and calling
|
||
// it after meant the confirmation was destroyed a moment later -- it has always
|
||
// flashed and vanished here. That is worse now than it was: `moved` reports that
|
||
// the default group changed, which is exactly the sentence somebody needs to
|
||
// read. So re-render first, then put the message into the fresh element.
|
||
var msg = '<div class="okbox">Uloženo. Nový člen to dostane při prvním ' +
|
||
"nastavení; už nastavené účty se tím nemění." + moved + "</div>";
|
||
await load();
|
||
if (el("tOut")) el("tOut").innerHTML = msg;
|
||
} catch (e) {
|
||
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
|
||
}
|
||
}
|
||
|
||
|
||
/* ── vault crypto: MUST match the app byte-for-byte ───────────────────────────
|
||
Lifted unchanged from index.html, which is itself pinned against
|
||
addon_config_crypto.dart by addon_config_crypto_interop_test. Do not "tidy"
|
||
the iteration count, the kdf id or the blob layout — a device that already
|
||
cached a key would stop being able to open the blob, and the failure looks
|
||
like a password reset rather than a bug.
|
||
|
||
key = PBKDF2-HMAC-SHA256(password, salt, 210000, 256 bit)
|
||
blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] ) */
|
||
var PBKDF2_ITERS = 210000;
|
||
var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS;
|
||
|
||
function b64e(u8){ var s = ""; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
|
||
function b64d(s){ var b = atob(s), u = new Uint8Array(b.length);
|
||
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u; }
|
||
function concatBytes(a, b){ var o = new Uint8Array(a.length + b.length);
|
||
o.set(a, 0); o.set(b, a.length); return o; }
|
||
|
||
async function deriveVaultKey(password, salt){
|
||
var base = await crypto.subtle.importKey(
|
||
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
|
||
var bits = await crypto.subtle.deriveBits(
|
||
{ name: "PBKDF2", hash: "SHA-256", salt: salt, iterations: PBKDF2_ITERS }, base, 256);
|
||
return crypto.subtle.importKey("raw", bits, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||
}
|
||
|
||
async function sealConfig(plaintext, password, saltB64){
|
||
var key = await deriveVaultKey(password, b64d(saltB64));
|
||
var nonce = crypto.getRandomValues(new Uint8Array(12));
|
||
var ct = new Uint8Array(await crypto.subtle.encrypt(
|
||
{ name: "AES-GCM", iv: nonce, tagLength: 128 }, key,
|
||
new TextEncoder().encode(plaintext)));
|
||
return b64e(concatBytes(nonce, ct));
|
||
}
|
||
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
|
||
|
||
/* Pre-load the family config for an account being created RIGHT NOW.
|
||
|
||
Three steps, and each one is why this could not be done before:
|
||
1. the account has no profile yet, and `addon_config` is keyed by profile.
|
||
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
|
||
profile before creating one -- explicitly so a second device does not
|
||
make a duplicate -- so a profile made here is adopted as the default on
|
||
first sign-in rather than fought over.
|
||
2. the blob is encrypted with a key derived from the USER'S password, which
|
||
the server never has. This page has it, once, because it just chose it.
|
||
3. the template is superuser-only, so no family code is needed here.
|
||
|
||
If they later change their password the blob stops opening and the app flips
|
||
`addonConfigNeedsReentry`, which is the documented, already-handled path. */
|
||
async function preloadSharedConfig(userId, name, password, tplId){
|
||
// By id, because the dashboard now asks WHICH group. Reading "the" template here
|
||
// is what made this preload family-only.
|
||
var tpl = null;
|
||
try { tpl = await api("/api/collections/onboarding_template/records/" + tplId); }
|
||
catch (_) { tpl = null; }
|
||
if (!tpl) throw new Error("sdílené nastavení téhle skupiny se nepodařilo načíst");
|
||
|
||
var pr = await fetch("/api/collections/profiles/records", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: tok },
|
||
body: JSON.stringify({ user: userId, name: name || "Me" }),
|
||
});
|
||
if (!pr.ok) throw new Error("profil se nepodařilo vytvořit");
|
||
var prof = await pr.json();
|
||
|
||
var cfg = {
|
||
addonUrl: tpl.addonUrl || "",
|
||
czechAddonUrl: tpl.czechAddonUrl || "",
|
||
tmdbKey: tpl.tmdbKey || "",
|
||
};
|
||
var salt = newSaltB64();
|
||
var body = {
|
||
blob: await sealConfig(JSON.stringify(cfg), password, salt),
|
||
salt: salt, kdf: KDF_ID, profile: prof.id,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
var cr = await fetch("/api/collections/addon_config/records", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: tok },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!cr.ok) throw new Error("zdroje se nepodařilo uložit");
|
||
}
|
||
|
||
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,
|
||
// 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);
|
||
}
|
||
var extra = "";
|
||
var gid = el("nGroup") ? el("nGroup").value : "";
|
||
if (gid) {
|
||
// The account EXISTS at this point. If pre-loading fails, say so instead
|
||
// of reporting a clean success or rolling the account back — a half-made
|
||
// account you were not told about is the worst of the three.
|
||
try {
|
||
await preloadSharedConfig(d.id, name, pass, gid);
|
||
extra = " Sdílené zdroje jsou nastavené — po přihlášení je uvidí sám.";
|
||
} catch (e2) {
|
||
extra = ' <b style="color:var(--warn)">Účet je založený, ale sdílené ' +
|
||
"nastavení se nepodařilo přiřadit (" + esc(e2.message) +
|
||
"). Zdroje si zadá sám kódem své skupiny.</b>";
|
||
}
|
||
}
|
||
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
|
||
"</code> je založený." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
|
||
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
|
||
el("nRating").value = "";
|
||
load();
|
||
} catch (e) {
|
||
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
|
||
}
|
||
}
|
||
|
||
|
||
function wire(){
|
||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
||
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
||
});
|
||
if (el("createBtn")) el("createBtn").onclick = createAccount;
|
||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-pw]"), function(b){
|
||
b.onclick = function(){ setPassword(b.getAttribute("data-pw"), b); };
|
||
});
|
||
if (el("nSave")) el("nSave").onclick = saveNotice;
|
||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){
|
||
b.onclick = function(){ setNoticeActive(b.getAttribute("data-notice"), b.getAttribute("data-to")); };
|
||
});
|
||
if (el("tSave")) el("tSave").onclick = saveTemplate;
|
||
if (el("tGroup")) el("tGroup").onchange = function(){
|
||
// Re-render from the selection. Unsaved edits in the fields are dropped, which
|
||
// is the honest behaviour: they belonged to the group you just left.
|
||
tplSel = el("tGroup").value; load();
|
||
};
|
||
if (el("tShow")) el("tShow").onchange = function(){
|
||
var ty = el("tShow").checked ? "text" : "password";
|
||
// The code is a shared secret too, so it hides and reveals with the rest.
|
||
["tAddon", "tCzech", "tTmdb", "tCode"].forEach(function(id){ el(id).type = ty; });
|
||
};
|
||
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 });
|
||
var tps = await allTemplates();
|
||
var ns = await records("notices", { sort: "-created", perPage: 20 })
|
||
.catch(function(){ return { items: [] }; });
|
||
el("main").innerHTML = viewNotices(ns.items || []) +
|
||
viewAdmin(us.items || [], tps) + viewTemplate(tps);
|
||
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>
|