diff --git a/pb_hooks/status.pb.js b/pb_hooks/status.pb.js index b09a390..1b6caf3 100644 --- a/pb_hooks/status.pb.js +++ b/pb_hooks/status.pb.js @@ -202,6 +202,103 @@ routerAdd("GET", "/api/status", (e) => { }) }, $apis.requireSuperuserAuth()) +// Error history, sliced by the things that explain it: which publish, which OS, +// which flavour. +// +// GET /api/status/errors?days=14 (superuser) +// +// **Rate, not raw count, is what attributes errors to a publish.** Versions have +// wildly different exposure — at the time of writing 1.1.4+13 had 177 records to +// 1.1.5+14's 20 — so raw counts make whichever build the family used most look +// worst. Every version row therefore carries total, errors and errors/total. +// +// **Flavour is not recorded yet.** The app's TelemetryService sends appVersion, +// platform and device, but nothing carries clean-vs-adult. This reads a top-level +// `flavor` field first and then `meta.flavor`, so the day the app starts sending +// it this endpoint needs no change; until then the column honestly reads unknown. +// +// The window is capped by retention, not by choice: client_logs.pb.js trims to 14 +// days on a nightly cron, so nothing older exists to graph. +routerAdd("GET", "/api/status/errors", (e) => { + let days = parseInt(e.request.url.query().get("days") || "14", 10) + if (!(days > 0) || days > 14) days = 14 // 14 = the retention ceiling + + const ROW_CAP = 5000 + const cutoffMs = Date.now() - days * 24 * 3600 * 1000 + const cutoff = new DateTime( + new Date(cutoffMs).toISOString().replace("T", " ").substring(0, 19) + ) + + let rows = [] + try { + rows = $app.findRecordsByFilter( + "client_logs", "created >= {:cutoff}", "-created", ROW_CAP, 0, { cutoff: cutoff } + ) + } catch (err) { + return e.json(500, { error: String(err && err.message ? err.message : err) }) + } + + const isError = (r) => { + const kind = (r.getString("kind") || "").toLowerCase() + const ev = r.getString("event") || "" + return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error" + } + // Forward-compatible: works the moment the app starts sending a flavour. + const flavourOf = (r) => { + const top = r.getString("flavor") || r.getString("flavour") + if (top) return top + const meta = r.getString("meta") || "" + const m = /"flavou?r"\s*:\s*"([a-z]+)"/.exec(meta) + return m ? m[1] : "neznámá" + } + + const days_ = {}, byVersion = {}, byPlatform = {}, byFlavour = {}, byEvent = {} + // Pre-seed every day so the graph has no gaps — a missing bar and a zero bar + // mean different things and must not look alike. + for (let i = 0; i < days; i++) { + const d = new Date(Date.now() - i * 24 * 3600 * 1000).toISOString().substring(0, 10) + days_[d] = { date: d, total: 0, errors: 0 } + } + + const bump = (bag, key, err) => { + if (!bag[key]) bag[key] = { key: key, total: 0, errors: 0 } + bag[key].total++ + if (err) bag[key].errors++ + } + + for (const r of rows) { + const err = isError(r) + const day = r.getDateTime("created").string().substring(0, 10) + if (days_[day]) { days_[day].total++; if (err) days_[day].errors++ } + bump(byVersion, r.getString("appVersion") || "?", err) + bump(byPlatform, r.getString("platform") || "?", err) + bump(byFlavour, flavourOf(r), err) + if (err) { + const ev = r.getString("event") || "?" + byEvent[ev] = (byEvent[ev] || 0) + 1 + } + } + + const rate = (o) => Object.assign({}, o, { + rate: o.total ? Math.round((o.errors / o.total) * 1000) / 10 : 0, + }) + const list = (bag) => Object.keys(bag).map((k) => rate(bag[k])) + .sort((a, b) => b.total - a.total) + + return e.json(200, { + window: days, + truncated: rows.length >= ROW_CAP, + sampled: rows.length, + // Oldest first, so the graph reads left-to-right as time. + days: Object.keys(days_).sort().map((k) => days_[k]), + byVersion: list(byVersion), + byPlatform: list(byPlatform), + byFlavour: list(byFlavour), + topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a]) + .slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })), + }) +}, $apis.requireSuperuserAuth()) + // Container logs for one service, proxied so the Coolify token stays server-side. // // Runtime logs ARE available from the Coolify API — /api/v1/applications//logs diff --git a/pb_public/status.html b/pb_public/status.html index dbdcbec..6cee48d 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -69,6 +69,17 @@ .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} @media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} } @@ -243,6 +254,77 @@ function panelAccounts(a){ ""; } +// 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 '

Chyby v čase

' + + '
' + esc(d && d.error || "—") + "
"; + 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 '' + t + "" + + '' + + (eh > 0 ? '' : "") + ""; + }).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 '' + + esc(days[i].date.substring(5)) + ""; + }).join("") + : ""; + + function tbl(title, rows, showRate){ + if (!rows || !rows.length) return ""; + var body = rows.map(function(r){ + return "" + esc(r.key) + "" + r.total + + "" + + r.errors + "" + + (showRate ? "" + r.rate + " %" : "") + ""; + }).join(""); + return '

' + esc(title) + "

" + + "" + + (showRate ? "" : "") + "" + body + "
záznamůchybpodíl
"; + } + + var flav = (d.byFlavour || []); + var flavKnown = flav.some(function(f){ return f.key !== "neznámá"; }); + + return '

Chyby v čase — ' + d.window + ' dní

' + + '' + + '' + bars + labels + "" + + '
záznamy' + + 'chyby' + + 'vzorek ' + d.sampled + + (d.truncated ? " (zkráceno)" : "") + "
" + + tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) + + tbl("Podle systému", d.byPlatform, true) + + tbl("Podle varianty", flav, true) + + (flavKnown ? "" : '

Varianta (clean/18+) se zatím do diagnostiky ' + + "neposílá — appVersion a platform ano. Až ji aplikace začne posílat, tenhle " + + "řádek se naplní sám, backend se měnit nemusí.

") + + tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){ + return { key: x.key, total: x.errors, errors: x.errors }; }), false) + + '

Historie je omezená retencí: client_logs se každou noc čistí ' + + "na 14 dní, starší data neexistují.

"; +} + async function showLog(uuid, btn){ var box = el("logbox"); box.innerHTML = '

načítám…

'; @@ -259,10 +341,13 @@ async function showLog(uuid, btn){ async function load(){ try { var d = await api("/api/status"); + var errs = null; + try { errs = await api("/api/status/errors"); } catch (_) { errs = { error: "nedostupné" }; } el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ"); el("main").innerHTML = panelServices(d.coolify) + panelReleases(d.releases) + - panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts); + panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) + + panelErrors(errs); Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){ b.onclick = function(){ showLog(b.getAttribute("data-log"), b); }; });