Show on the dashboard which outside services are alive

Added after a day spent establishing by hand that AniList had been refusing
every query for days. Nothing said so: the app fell back, the fallback was
quietly wrong about episodes, and the first anyone knew was a family member
reporting a rail of week-old episodes.

What makes the section useful rather than decorative is the two columns beside
the light. "anilist: down" only says to go and look; "anilist: nedostupné,
anime katalog a kalendář, beze změny od pátku" is the whole answer, including
the sentence AniList itself puts in the body of its 403.

Uptime over a day and a week, from raw samples taken every five minutes, so the
question "was it flaky or has it been out since Friday" has an answer on the
screen rather than in a log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-07 22:05:42 +02:00
parent bc6ab04673
commit 7a40dd908d
2 changed files with 57 additions and 1 deletions

View file

@ -191,7 +191,11 @@ routerAdd("GET", "/api/status", (e) => {
const stats = fetchJson(base.replace(/\/+$/, "") + "/v1/admin/stats",
{ "x-amber-admin": secret }, 8)
return { configured: true, probe: stats.probe, queue: stats.queue,
metadata: stats.metadata, health: stats.health }
metadata: stats.metadata, health: stats.health,
// Upstream liveness rides along on the same call rather than a
// second round trip: it is produced by the same service and read
// by the same screen.
upstreams: stats.upstreams }
})
return e.json(200, {

View file

@ -366,6 +366,57 @@ function panelApi(a){
"</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);
}
function panelAccounts(a){
if (!a || a.error) return "";
return '<section><h2>Účty</h2><div class="kv">' +
@ -1140,6 +1191,7 @@ async function load(){
el("main").innerHTML =
panelServices(d.coolify) + panelReleases(d.releases) +
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) +
panelUpstreams(d.amberApi) +
panelErrors(errs);
wire();
} catch (e) {