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:
parent
cd5d7138d9
commit
48a88e6936
2 changed files with 183 additions and 1 deletions
|
|
@ -202,6 +202,103 @@ routerAdd("GET", "/api/status", (e) => {
|
||||||
})
|
})
|
||||||
}, $apis.requireSuperuserAuth())
|
}, $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.
|
// 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
|
// Runtime logs ARE available from the Coolify API — /api/v1/applications/<uuid>/logs
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,17 @@
|
||||||
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
|
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
|
||||||
.svc a:hover{color:var(--accent)}
|
.svc a:hover{color:var(--accent)}
|
||||||
.tag{color:var(--accent);font-size:11px;white-space:nowrap}
|
.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} }
|
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -243,6 +254,77 @@ function panelAccounts(a){
|
||||||
"</div></section>";
|
"</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){
|
async function showLog(uuid, btn){
|
||||||
var box = el("logbox");
|
var box = el("logbox");
|
||||||
box.innerHTML = '<p class="note">načítám…</p>';
|
box.innerHTML = '<p class="note">načítám…</p>';
|
||||||
|
|
@ -259,10 +341,13 @@ async function showLog(uuid, btn){
|
||||||
async function load(){
|
async function load(){
|
||||||
try {
|
try {
|
||||||
var d = await api("/api/status");
|
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("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ");
|
||||||
el("main").innerHTML =
|
el("main").innerHTML =
|
||||||
panelServices(d.coolify) + panelReleases(d.releases) +
|
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){
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
||||||
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue