The record used to arrive only when a run was over, so "how far is it" could only be answered by looking at files on the machine doing the work -- which the owner could not see at all, and which is a poor way for me to watch it either. The runner opens the record before the first case and updates it after each one, and the Testy tab draws a bar with the case in flight underneath. Publishing can never fail a run: if the server is unreachable the record is not opened and every later update is a no-op. The page follows a live run every five seconds instead of waiting out the ordinary minute, and goes back to sleep when nothing is moving. A run that dies leaves its record saying "running" for ever, so the card reads how long ago it last moved rather than trusting the flag: five minutes without progress reads as "běh se zastavil", which is generous on purpose -- a source picker legitimately waits 150 seconds on one case. Screenshots still go at the end. They are the slow part and nothing on the dashboard needs them while the bar is still moving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1469 lines
72 KiB
HTML
1469 lines
72 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}
|
||
/* A run is a card with its own verdict line, and its cases are a list under a
|
||
<details> — a run has ninety of them and nobody wants ninety rows open. */
|
||
/* A run in flight: the bar is the whole point, so it sits directly under the
|
||
heading rather than among the counts. */
|
||
.bar{height:8px;border-radius:999px;background:var(--panel2);overflow:hidden;
|
||
margin:10px 0 6px;border:1px solid var(--line)}
|
||
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s}
|
||
.bar.stalled i{background:var(--warn)}
|
||
.runningnow{color:var(--muted);font-size:12.5px;display:flex;gap:10px;
|
||
align-items:center;flex-wrap:wrap}
|
||
.runningnow code{color:var(--text)}
|
||
.live{background:rgba(240,166,60,.16);color:var(--warn)}
|
||
.run{border:1px solid var(--line);border-radius:10px;padding:14px 16px;
|
||
margin-bottom:10px;background:var(--panel2)}
|
||
.run h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px;
|
||
flex-wrap:wrap}
|
||
.run .when{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
|
||
.run details{margin-top:10px}
|
||
.run summary{cursor:pointer;color:var(--muted);font-size:12.5px}
|
||
.case{display:flex;gap:10px;align-items:baseline;padding:5px 0;
|
||
border-bottom:1px solid var(--line);font-size:13px}
|
||
.case:last-child{border-bottom:none}
|
||
.case .cid{font-family:var(--mono);font-size:12px;min-width:230px}
|
||
.case .msg{color:var(--muted)}
|
||
.shots{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||
.shots a{display:block;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||
.shots img{display:block;width:150px;height:auto}
|
||
.shots .cap{font-size:11px;color:var(--muted);padding:3px 5px;font-family:var(--mono)}
|
||
.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="e2e">Testy</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 za 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>";
|
||
}
|
||
|
||
// Which of the services we depend on are alive, and how much of the time they
|
||
// have been.
|
||
//
|
||
// This section exists because AniList was down for days and nothing said so:
|
||
// the app fell back, the fallback was quietly wrong, and the first anyone knew
|
||
// was a family member reporting a rail of stale episodes. What makes it useful
|
||
// rather than decorative is the two columns beside the light — what breaks when
|
||
// this one is out, and since when — because "anilist: down" only says to go and
|
||
// look, while "anilist: down since pátek, anime katalog" is the whole answer.
|
||
function panelUpstreams(a){
|
||
var u = a && a.upstreams;
|
||
if (!u || !u.length) return '<section class="wide"><h2>Cizí služby</h2>' +
|
||
'<p class="note">zatím žádné měření</p></section>';
|
||
var down = 0;
|
||
var rows = u.map(function(x){
|
||
if (!x.ok) down++;
|
||
var pill = x.ok ? '<span class="pill ok">běží</span>'
|
||
: '<span class="pill no">nedostupné</span>';
|
||
// A percentage is only meaningful once there is something to average.
|
||
var day = x.uptime24h === null ? "—" : x.uptime24h + " %";
|
||
var week = x.uptime7d === null ? "—" : x.uptime7d + " %";
|
||
// The reason, when the service gave one. AniList says why it is off in the
|
||
// body of a 403, and that sentence saves the next person an investigation.
|
||
var why = x.detail ? '<div class="note">' + esc(String(x.detail).slice(0,110)) + "</div>" : "";
|
||
return "<tr><td>" + pill + "</td><td><b>" + esc(x.name) + "</b>" + why +
|
||
'</td><td class="note">' + esc(x.matters || "") + "</td>" +
|
||
'<td class="note">' + (x.since ? esc(sinceLabel(x.since)) : "—") + "</td>" +
|
||
'<td class="num">' + day + '</td><td class="num">' + week +
|
||
'</td><td class="num">' + (x.ms || 0) + " ms</td></tr>";
|
||
}).join("");
|
||
return '<section class="wide"><h2>Cizí služby' +
|
||
(down ? ' <span class="pill no">' + down + " mimo provoz</span>" : "") + "</h2>" +
|
||
"<table><tr><th></th><th>služba</th><th>na čem záleží</th><th>beze změny od</th>" +
|
||
'<th class="num">24 h</th><th class="num">7 dní</th><th class="num">odezva</th></tr>' +
|
||
rows + "</table>" +
|
||
'<p class="note">Měří se každých pět minut. Sloupce 24 h a 7 dní jsou podíl měření, ' +
|
||
"kdy služba odpovídala.</p></section>";
|
||
}
|
||
|
||
// "od pátku 14:05" beats an ISO timestamp on a wall-mounted dashboard, and a
|
||
// relative age beats both once it is under a day.
|
||
function sinceLabel(unix){
|
||
var d = new Date(unix * 1000), now = Date.now();
|
||
var mins = Math.round((now - d.getTime()) / 60000);
|
||
if (mins < 60) return "před " + mins + " min";
|
||
if (mins < 60 * 24) return "před " + Math.round(mins / 60) + " h";
|
||
var days = ["neděle","pondělí","úterý","středa","čtvrtek","pátek","sobota"];
|
||
return days[d.getDay()] + " " + ("0" + d.getHours()).slice(-2) + ":" +
|
||
("0" + d.getMinutes()).slice(-2);
|
||
}
|
||
|
||
// Whose bytes were slow. Not a ranking input and deliberately not per file: a
|
||
// stall is a property of neither the upload nor the hostname, because TorBox
|
||
// hands out CDN nodes per request. This exists to answer "was it them or us"
|
||
// for a person looking at a screen, which is the question that took an evening
|
||
// of measurement the first time it was asked.
|
||
function panelThroughput(a){
|
||
var t = a && a.throughput;
|
||
if (!t || !t.length) return "";
|
||
var rows = t.map(function(x){
|
||
// Mbit/s reads the way a person thinks about a connection; kbps does not.
|
||
var med = (x.medianKbps / 1000).toFixed(1);
|
||
var worst = x.worstMinKbps === null ? "—" : (x.worstMinKbps / 1000).toFixed(1);
|
||
// Time spent waiting is the number that matches what the viewer felt.
|
||
var stall = x.stalledPct === null ? "—" : x.stalledPct + " %";
|
||
var bad = x.stalledPct !== null && x.stalledPct >= 2;
|
||
return "<tr><td><b>" + esc(x.provider) + '</b></td><td class="num">' + x.sessions +
|
||
'</td><td class="num">' + med + '</td><td class="num">' + worst +
|
||
'</td><td class="num"' + (bad ? ' style="color:var(--warn)"' : "") + ">" + stall +
|
||
"</td></tr>";
|
||
}).join("");
|
||
return '<section class="wide"><h2>Rychlost zdrojů za 24 h</h2><table>' +
|
||
'<tr><th>poskytovatel</th><th class="num">relací</th><th class="num">medián Mb/s</th>' +
|
||
'<th class="num">nejhorší propad</th><th class="num">čekání</th></tr>' + rows +
|
||
'</table><p class="note">Medián, ne průměr: jedna rychlá relace nemá schovat deset pomalých. ' +
|
||
"Sloupec čekání je podíl sledovaného času stráveného načítáním.</p></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 za ' + 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>";
|
||
}
|
||
|
||
/* ══ Testy ═════════════════════════════════════════════════════════════════════
|
||
What the release gate found. The catalogue lives in amber-app
|
||
(docs/e2e/catalogue.yaml) and says what a person must be able to do before a
|
||
build goes out; the runner executes it and posts one e2e_runs record per run.
|
||
|
||
Five outcomes, not two, and the distinction is the point: a case that could
|
||
not be asked must never read as a pass. "Nelze zde" is a target that cannot
|
||
answer (no keychain on the desktop sandbox), "čeká na tebe" is something only
|
||
the owner can unblock (a missing PIN), and "nenapsáno" is work. */
|
||
|
||
function outcomePill(o){
|
||
if (o === "pass") return '<span class="pill ok">prošlo</span>';
|
||
if (o === "fail") return '<span class="pill no">spadlo</span>';
|
||
if (o === "blocked") return '<span class="pill wn">čeká na tebe</span>';
|
||
if (o === "unsupported") return '<span class="pill un">nelze zde</span>';
|
||
return '<span class="pill un">nenapsáno</span>';
|
||
}
|
||
|
||
// A run that dies leaves its record saying "running" for ever, so "is it alive"
|
||
// is answered by when it last moved rather than by the flag alone. Two minutes:
|
||
// a case can legitimately take a while (a source picker waits 150s), so this is
|
||
// deliberately generous and only ever says "stalled", never "failed".
|
||
var STALL_MS = 300000;
|
||
|
||
function isLive(r){
|
||
return r.status === "running" &&
|
||
(Date.now() - Date.parse(String(r.updated).replace(" ", "T") + "Z")) < STALL_MS;
|
||
}
|
||
|
||
function isStalled(r){
|
||
return r.status === "running" && !isLive(r);
|
||
}
|
||
|
||
function runVerdict(c, r){
|
||
if (r && isLive(r)) return '<span class="pill live">běží</span>';
|
||
if (r && isStalled(r)) return '<span class="pill wn">běh se zastavil</span>';
|
||
// A build ships when nothing that blocks a release has failed. Cases nobody
|
||
// has written yet are not a verdict, they are a gap, and the card says both.
|
||
if ((c.fail || 0) > 0) return '<span class="pill no">nevydávat</span>';
|
||
if ((c.blocked || 0) > 0) return '<span class="pill wn">něco čeká na tebe</span>';
|
||
return '<span class="pill ok">nic nebrání vydání</span>';
|
||
}
|
||
|
||
function progressBar(r){
|
||
if (r.status !== "running") return "";
|
||
var total = r.total || 0, done = r.done || 0;
|
||
var pct = total ? Math.round((done / total) * 100) : 0;
|
||
return '<div class="bar' + (isStalled(r) ? " stalled" : "") + '">' +
|
||
'<i style="width:' + pct + '%"></i></div>' +
|
||
'<div class="runningnow"><span>' + done + " z " + total + "</span>" +
|
||
(r.current ? "<code>" + esc(r.current) + "</code>" : "") +
|
||
(isStalled(r) ? "<span>naposledy se pohnul " +
|
||
esc(String(r.updated).substring(11, 16)) + "</span>" : "") +
|
||
"</div>";
|
||
}
|
||
|
||
// PocketBase rewrites an uploaded filename: it flattens everything that is not
|
||
// a letter or a digit to "_", collapses runs of them, and appends a random
|
||
// token. So `signin.happy__home.png` arrives as `signin_happy_home_czik8h4h7b`.
|
||
// Matching therefore normalises both sides the same way rather than trusting the
|
||
// name to survive -- a prefix match on the raw name found nothing at all.
|
||
function normName(s){
|
||
return String(s).toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "_");
|
||
}
|
||
|
||
// A short-lived token for the protected files, fetched once per render.
|
||
//
|
||
// `e2e_runs` is superuser-only, which makes its files protected, which means an
|
||
// <img> tag cannot fetch one on the strength of the page's own session -- the
|
||
// browser sends no Authorization header with an image request. Without this
|
||
// every screenshot on the page is a broken icon.
|
||
var fileTok = "";
|
||
// Set while a run is in flight, so the page follows it instead of waiting out
|
||
// the ordinary minute. Nothing else on this dashboard changes that fast.
|
||
var liveRun = false;
|
||
|
||
function shotUrl(r, caseId, shotName){
|
||
var want = normName(caseId + "_" + shotName);
|
||
var hit = (r.shots || []).filter(function(f){
|
||
return normName(f).indexOf(want) === 0;
|
||
})[0];
|
||
if (!hit) return null;
|
||
return "/api/files/e2e_runs/" + r.id + "/" + hit +
|
||
(fileTok ? "?token=" + encodeURIComponent(fileTok) : "");
|
||
}
|
||
|
||
function caseRow(r, c){
|
||
var shots = (c.evidence || []).filter(function(e){ return e.kind === "screenshot"; });
|
||
var pics = shots.map(function(s){
|
||
var u = shotUrl(r, c.id, s.name);
|
||
return u ? '<a href="' + u + '" target="_blank"><img loading="lazy" src="' + u +
|
||
'"><div class="cap">' + esc(s.name) + "</div></a>" : "";
|
||
}).join("");
|
||
// What to say beside the id, in the order that answers the question the
|
||
// outcome raises. A blocked case is asking the owner for something, so its
|
||
// message is that request; a failed one is asking about the app, so it is the
|
||
// claim that did not hold. Reading the last claim for everything showed a
|
||
// blocked case saying "and the app knows it is a child profile", which is the
|
||
// last thing it managed to check and not the reason it stopped.
|
||
var checks = (c.evidence || []).filter(function(e){ return e.kind === "checked"; });
|
||
var broke = checks.filter(function(e){ return e.ok === false; })[0];
|
||
var claim = c.outcome === "blocked" ? (c.needs || "")
|
||
: c.outcome === "fail" ? ((broke && broke.claim) || c.error || "")
|
||
: (checks.length ? checks[checks.length - 1].claim : "");
|
||
return '<div class="case">' + outcomePill(c.outcome) +
|
||
'<span class="cid">' + esc(c.id) + "</span>" +
|
||
(c.blocker ? '<span class="pill un">blokuje</span>' : "") +
|
||
'<span class="msg">' + esc(String(claim).split("\n").pop().substring(0, 160)) +
|
||
"</span></div>" + (pics ? '<div class="shots">' + pics + "</div>" : "");
|
||
}
|
||
|
||
function runCard(r){
|
||
var c = r.counts || {};
|
||
var cases = r.cases || [];
|
||
var interesting = cases.filter(function(x){
|
||
return x.outcome === "fail" || x.outcome === "blocked";
|
||
});
|
||
var rest = cases.filter(function(x){ return x.outcome === "pass"; });
|
||
var quiet = cases.filter(function(x){
|
||
return x.outcome === "unsupported" || x.outcome === "unimplemented";
|
||
});
|
||
return '<div class="run"><h3>' + runVerdict(c, r) +
|
||
'<span class="pill un">' + esc(r.target) + "</span>" +
|
||
"<code>" + esc(r.appVersion) + "</code><code>" + esc(r.commit) + "</code>" +
|
||
'<span class="grow"></span><span class="when">' +
|
||
esc(String(r.created).substring(0, 16)) + "</span></h3>" +
|
||
progressBar(r) +
|
||
'<div class="kv">' +
|
||
'<div><div class="lbl">prošlo</div><div class="big">' + (c.pass || 0) + "</div></div>" +
|
||
'<div><div class="lbl">spadlo</div><div class="big">' + (c.fail || 0) + "</div></div>" +
|
||
'<div><div class="lbl">čeká na tebe</div><div class="big">' + (c.blocked || 0) + "</div></div>" +
|
||
'<div><div class="lbl">nelze zde</div><div class="big">' + (c.unsupported || 0) + "</div></div>" +
|
||
'<div><div class="lbl">nenapsáno</div><div class="big">' + (c.unimplemented || 0) + "</div></div>" +
|
||
"</div>" +
|
||
(r.note ? '<div class="warnbox" style="margin-top:10px">' + esc(r.note) + "</div>" : "") +
|
||
(interesting.length
|
||
? "<div style=\"margin-top:12px\">" +
|
||
interesting.map(function(x){ return caseRow(r, x); }).join("") + "</div>"
|
||
: "") +
|
||
(rest.length
|
||
? "<details><summary>" + rest.length + " prošlých, i s důkazy</summary>" +
|
||
rest.map(function(x){ return caseRow(r, x); }).join("") + "</details>"
|
||
: "") +
|
||
(quiet.length
|
||
? "<details><summary>" + quiet.length +
|
||
" nelze zde nebo nenapsáno</summary>" +
|
||
quiet.map(function(x){ return caseRow(r, x); }).join("") + "</details>"
|
||
: "") +
|
||
"</div>";
|
||
}
|
||
|
||
function viewE2e(runs){
|
||
if (!runs.length) {
|
||
return '<section class="wide"><h2>Testy: co brána našla</h2>' +
|
||
'<div class="warnbox">Zatím tu není žádný běh. Runner je v amber-app: ' +
|
||
"<code>tools/e2e/runner.py run --target linux</code>, a nahraje se sem " +
|
||
"přes <code>tools/e2e/publish.py</code>.</div></section>";
|
||
}
|
||
// Newest first, and the newest per target at the top, because the question is
|
||
// always "kde to stojí teď" before "co bylo".
|
||
var latest = {};
|
||
runs.forEach(function(r){ if (!latest[r.target]) latest[r.target] = r; });
|
||
var heads = Object.keys(latest).map(function(k){ return runCard(latest[k]); }).join("");
|
||
var older = runs.filter(function(r){ return latest[r.target] !== r; });
|
||
|
||
return '<section class="wide"><h2>Testy: kde to stojí teď</h2>' + heads +
|
||
'<p class="note">Katalog je v amber-app: <code>docs/e2e/catalogue.yaml</code>. ' +
|
||
"Případ, na který se tady nedá zeptat, se hlásí jako „nelze zde“, ne jako " +
|
||
"prošlý — brána, která mlčí, je horší než žádná.</p></section>" +
|
||
(older.length
|
||
? '<section class="wide"><h2>Dřívější běhy</h2>' +
|
||
older.map(runCard).join("") + "</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. Hodí se to, když reset ' +
|
||
"nedorazí, protože z odpovědi serveru to stejně nepoznáš: PocketBase odpoví " +
|
||
"„odesláno“ i na adresu, ke které žádný účet není, aby přes něj nešlo " +
|
||
"zjišťovat, kdo je zaregistrovaný.</p>" +
|
||
'<p class="note">Když si reset posíláš na svoji vlastní adresu, hledej ho ve ' +
|
||
"Všechna pošta. Odesílatel sedí na stejném Proton účtu, takže se uloží mezi " +
|
||
"odeslané a do Doručené se nedostane.</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 === "e2e") {
|
||
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
|
||
.catch(function(){ return { items: [] }; });
|
||
liveRun = (rs.items || []).some(isLive);
|
||
fileTok = "";
|
||
try {
|
||
var ft = await api("/api/files/token", { method: "POST" });
|
||
fileTok = ft.token || "";
|
||
} catch (_) { /* the cards still render; the pictures will not */ }
|
||
el("main").innerHTML = viewE2e(rs.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) +
|
||
panelUpstreams(d.amberApi) + panelThroughput(d.amberApi) +
|
||
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 for everything here except a test run in flight, which
|
||
// moves every few seconds and is worth watching. The admin view is excluded
|
||
// because a refresh under a half-typed form would wipe it.
|
||
var ticks = 0;
|
||
timer = setInterval(function(){
|
||
if (prefs.view === "admin") return;
|
||
ticks++;
|
||
var following = liveRun && prefs.view === "e2e";
|
||
if (following || ticks >= 12) { ticks = 0; load(); }
|
||
}, 5000);
|
||
}
|
||
|
||
if (tok) start(); else el("login").hidden = false;
|
||
</script>
|
||
</body>
|
||
</html>
|