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.
This commit is contained in:
Claude 2026-08-05 21:34:42 +02:00
parent cd5d7138d9
commit 48a88e6936
2 changed files with 183 additions and 1 deletions

View file

@ -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/<uuid>/logs

View file

@ -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} }
</style>
</head>
@ -243,6 +254,77 @@ function panelAccounts(a){
"</div></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>";
}
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>";
}
async function showLog(uuid, btn){
var box = el("logbox");
box.innerHTML = '<p class="note">načítám…</p>';
@ -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); };
});