Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
|
|
|
<!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)}
|
|
|
|
|
.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{background:var(--panel2);color:var(--text);border:1px solid var(--line);
|
|
|
|
|
border-radius:8px;padding:9px 11px;font:inherit;width:100%}
|
|
|
|
|
.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}
|
|
|
|
|
pre{margin:8px 0 0;background:#0b0c10;border:1px solid var(--line);border-radius:8px;
|
|
|
|
|
padding:10px;max-height:340px;overflow:auto;font-family:var(--mono);font-size:11.5px;
|
|
|
|
|
white-space:pre-wrap;word-break:break-word}
|
|
|
|
|
.login{max-width:340px;margin:14vh auto;padding:0 16px}
|
|
|
|
|
.login section{padding:18px}
|
|
|
|
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
|
|
|
.svc{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--panel2)}
|
|
|
|
|
.svc:last-child{border-bottom:none}
|
|
|
|
|
.svc .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
|
|
|
|
|
.svc a:hover{color:var(--accent)}
|
|
|
|
|
.tag{color:var(--accent);font-size:11px;white-space:nowrap}
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
.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}
|
Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
|
|
|
@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 služeb</h1>
|
|
|
|
|
<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;
|
|
|
|
|
|
|
|
|
|
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>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function api(path){
|
|
|
|
|
var r = await fetch(path, { headers: { Authorization: tok } });
|
|
|
|
|
if (r.status === 401) { logout(); throw new Error("401"); }
|
|
|
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
|
|
return r.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(); };
|
|
|
|
|
|
|
|
|
|
function panelServices(c){
|
|
|
|
|
if (!c || c.configured === false) {
|
|
|
|
|
return '<section><h2>Služby</h2><div class="warnbox">' + esc(c && c.why || "nenastaveno") +
|
|
|
|
|
"</div></section>";
|
|
|
|
|
}
|
|
|
|
|
if (c.error) {
|
|
|
|
|
return '<section><h2>Služby</h2><div class="warnbox">Coolify neodpovědělo: ' +
|
|
|
|
|
esc(c.error) + "</div></section>";
|
|
|
|
|
}
|
|
|
|
|
var rows = (c.apps || []).map(function(a){
|
|
|
|
|
return '<div class="svc">' + statusPill(a.status) +
|
|
|
|
|
'<span class="nm">' + esc(a.name) + "</span>" +
|
|
|
|
|
(a.note ? '<span class="tag">' + esc(a.note) + "</span>" : "") +
|
|
|
|
|
(a.fqdn ? '<a href="' + esc(a.fqdn.split(",")[0]) + '" target="_blank" rel="noopener">otevřít</a>' : "") +
|
|
|
|
|
'<button data-log="' + esc(a.uuid) + '">log</button></div>';
|
|
|
|
|
}).join("");
|
|
|
|
|
return '<section class="wide"><h2>Služby</h2>' + (rows || '<p class="note">nic</p>') +
|
|
|
|
|
'<div id="logbox"></div></section>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function panelReleases(rs){
|
|
|
|
|
if (!rs || rs.error) return '<section><h2>Vydání</h2><p class="note">' +
|
|
|
|
|
esc(rs && rs.error || "—") + "</p></section>";
|
|
|
|
|
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
|
|
|
|
|
var body = rs.map(function(r){
|
|
|
|
|
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
|
|
|
|
|
"</td><td class='mono'>" + esc(r.version) + "+" + r.build +
|
|
|
|
|
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
|
|
|
|
|
}).join("");
|
|
|
|
|
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
|
|
|
|
|
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
|
|
|
|
|
body + "</table></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ášť.</p></section>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function panelApi(a){
|
|
|
|
|
if (!a || a.configured === false) {
|
|
|
|
|
return '<section><h2>amber-api</h2><div class="warnbox">' +
|
|
|
|
|
esc(a && a.why || "nenastaveno") + "</div></section>";
|
|
|
|
|
}
|
|
|
|
|
if (a.error) return '<section><h2>amber-api</h2><div class="warnbox">' +
|
|
|
|
|
esc(a.error) + "</div></section>";
|
|
|
|
|
var p = a.probe || {}, q = a.queue || {}, m = a.metadata || {}, h = a.health || {};
|
|
|
|
|
return '<section><h2>amber-api</h2><div class="kv">' +
|
|
|
|
|
'<div><div class="lbl">hotové sondy</div><div class="big">' + (p.ready || 0) + "</div></div>" +
|
|
|
|
|
'<div><div class="lbl">ve frontě</div><div class="big">' + (q.queued || 0) + "</div></div>" +
|
|
|
|
|
'<div><div class="lbl">selhalo</div><div class="big" style="color:' +
|
|
|
|
|
((p.failed || p.permafail) ? "var(--warn)" : "var(--muted)") + '">' +
|
|
|
|
|
((p.failed || 0) + (p.permafail || 0)) + "</div></div>" +
|
|
|
|
|
"</div><table style='margin-top:10px'>" +
|
|
|
|
|
"<tr><td>metadata (řádků / zásahů)</td><td class='num'>" + (m.rows || 0) + " / " + (m.hits || 0) + "</td></tr>" +
|
|
|
|
|
"<tr><td>zdraví zdrojů (klíčů)</td><td class='num'>" + (h.scoredKeys || 0) + "</td></tr>" +
|
|
|
|
|
"<tr><td>hostů v ochlazení</td><td class='num'>" + (q.hostsCoolingDown || 0) + "</td></tr>" +
|
|
|
|
|
"</table></section>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function panelAccounts(a){
|
|
|
|
|
if (!a || a.error) return "";
|
|
|
|
|
return '<section><h2>Účty</h2><div class="kv">' +
|
|
|
|
|
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
|
|
|
|
|
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
|
|
|
|
|
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
|
|
|
|
|
"</div></section>";
|
|
|
|
|
}
|
|
|
|
|
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
// 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>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var flav = (d.byFlavour || []);
|
|
|
|
|
var flavKnown = flav.some(function(f){ return f.key !== "neznámá"; });
|
|
|
|
|
|
|
|
|
|
return '<section class="wide"><h2>Chyby v čase — ' + d.window + ' dní</h2>' +
|
|
|
|
|
'<svg class="chart" viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" ' +
|
|
|
|
|
'role="img" aria-label="chyby za posledních ' + d.window + ' dní">' +
|
|
|
|
|
'<line class="grid" x1="' + PAD + '" y1="' + (H - PAD) + '" x2="' + (W - PAD) +
|
|
|
|
|
'" y2="' + (H - PAD) + '"/>' + bars + labels + "</svg>" +
|
|
|
|
|
'<div class="legend"><span><i class="tot" style="background:var(--line)"></i>záznamy</span>' +
|
|
|
|
|
'<span><i style="background:var(--bad)"></i>chyby</span>' +
|
|
|
|
|
'<span style="margin-left:auto">vzorek ' + d.sampled +
|
|
|
|
|
(d.truncated ? " (zkráceno)" : "") + "</span></div>" +
|
|
|
|
|
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
|
|
|
|
|
tbl("Podle systému", d.byPlatform, true) +
|
|
|
|
|
tbl("Podle varianty", flav, true) +
|
|
|
|
|
(flavKnown ? "" : '<p class="note">Varianta (clean/18+) se zatím do diagnostiky ' +
|
|
|
|
|
"neposílá — appVersion a platform ano. Až ji aplikace začne posílat, tenhle " +
|
|
|
|
|
"řádek se naplní sám, backend se měnit nemusí.</p>") +
|
|
|
|
|
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>";
|
|
|
|
|
}
|
|
|
|
|
|
Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
|
|
|
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>";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load(){
|
|
|
|
|
try {
|
|
|
|
|
var d = await api("/api/status");
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
var errs = null;
|
|
|
|
|
try { errs = await api("/api/status/errors"); } catch (_) { errs = { error: "nedostupné" }; }
|
Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
|
|
|
el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ");
|
|
|
|
|
el("main").innerHTML =
|
|
|
|
|
panelServices(d.coolify) + panelReleases(d.releases) +
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) +
|
|
|
|
|
panelErrors(errs);
|
Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
|
|
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
|
|
|
|
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
|
|
|
|
});
|
|
|
|
|
} 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;
|
|
|
|
|
load();
|
|
|
|
|
if (timer) clearInterval(timer);
|
|
|
|
|
timer = setInterval(load, 60000); // a minute is plenty; this is not a metrics system
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tok) start(); else el("login").hidden = false;
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|