amber-backend/pb_hooks/status.pb.js
Claude 7a40dd908d 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>
2026-09-07 22:05:42 +02:00

329 lines
14 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// Operator status dashboard — the data behind pb_public/status.html.
//
// GET /api/status (superuser) → one JSON snapshot
// GET /api/status/logs?app=<uuid>&lines=N (superuser) → a service's container logs
//
// WHY THIS EXISTS: answering "is Amber healthy?" meant opening Coolify's UI,
// clicking through 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 that get 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.
//
// **Superuser-gated, not user-gated.** There is no "operator" flag on `users` and
// inventing one would need a migration; the owner already has superuser
// credentials for /_/, so that is the honest gate. `nsfwEnabled` was considered
// and rejected — it is a content flag, and using it as an admin flag would mean
// any adult-enabled family member could read infrastructure logs.
//
// **This is the first hook in the project to make an outbound HTTP request.**
// Everything before it read only PocketBase's own database (see the note in
// amber-app/CLAUDE.md about the JSVM having no outbound-HTTP precedent). Two
// consequences, both handled below: every external call is wrapped so a dead
// dependency degrades one panel instead of failing the whole response, and every
// call carries a short timeout so a hung upstream cannot pin a PocketBase worker.
//
// **The Coolify token never reaches the browser.** That is the entire reason this
// endpoint exists rather than the page calling Coolify directly.
//
// The variable is COOLIFY_**READ**_TOKEN, and the name is the point: this dashboard
// only ever performs GETs, so it must never be handed the write-capable token that
// `coolify.sh` uses for deploys. A full token here would mean a PocketBase
// vulnerability could deploy or stop every app on the PaaS.
//
// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so
// anything a handler needs must be declared INSIDE it — file-scope helpers throw
// ReferenceError at request time. Nothing is hoisted here; keep it that way.
// Pretty URL, following the same pattern as /settings → /settings.html.
//
// Without it, `/status` does NOT 404 — PocketBase's `indexFallback` (on by
// default) quietly serves index.html for any unmatched static path, so the URL
// looked like it worked and returned the family landing page instead. The docs
// were written with the pretty URL, so the docs were wrong until this existed.
routerAdd("GET", "/status", (e) => e.redirect(302, "/status.html"))
routerAdd("GET", "/api/status", (e) => {
// ---- helpers, all handler-local (isolated JSVM) ------------------------
const safe = (label, fn) => {
try {
return fn()
} catch (err) {
return { error: String(err && err.message ? err.message : err), _label: label }
}
}
const fetchJson = (url, headers, timeoutSec) => {
const res = $http.send({
url: url,
method: "GET",
headers: headers || {},
timeout: timeoutSec || 6,
})
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error("HTTP " + res.statusCode)
}
return res.json
}
const env = (k) => {
const v = $os.getenv(k)
return v && v.length ? v : null
}
// ---- what the family is being served (local DB, always available) ------
const releases = safe("releases", () => {
// Newest build per platform. Three rows in practice; a 200-row window is
// plenty and bounds the query regardless of history size.
//
// Filtered to variant='clean' rather than reading whatever is newest: rows
// from the 18+ flavour are still in this collection, and a leftover one
// could otherwise be reported as what the family is being served.
const rows = $app.findRecordsByFilter(
"releases", "variant = 'clean'", "-buildNumber", 200, 0)
const newest = {}
for (const r of rows) {
const key = r.getString("platform")
if (newest[key]) continue
newest[key] = {
platform: r.getString("platform"),
version: r.getString("version"),
build: r.getInt("buildNumber"),
size: r.getInt("size"),
sha8: r.getString("sha256").substring(0, 8),
published: r.getDateTime("created").string(),
}
}
return Object.keys(newest).sort().map((k) => newest[k])
})
// ---- are clients erroring (local DB) ----------------------------------
const clients = safe("clients", () => {
const since = new DateTime()
// 24h back. dateTime arithmetic in the JSVM is awkward, so compare on a
// string built from the epoch — client_logs.created is an autodate.
const cutoff = new DateTime(
new Date(Date.now() - 24 * 3600 * 1000).toISOString().replace("T", " ").substring(0, 19)
)
const rows = $app.findRecordsByFilter(
"client_logs",
"created >= {:cutoff}",
"-created",
2000,
0,
{ cutoff: cutoff }
)
const byEvent = {}
let errors = 0
let stutter = 0
for (const r of rows) {
const ev = r.getString("event") || "?"
byEvent[ev] = (byEvent[ev] || 0) + 1
const kind = (r.getString("kind") || "").toLowerCase()
if (kind === "error" || ev === "uncaught" || ev === "player_error") errors++
// A viewer saying it stuttered while the counters saw nothing is the
// documented blind spot worth surfacing, not burying in a total.
const meta = r.getString("meta") || ""
if (meta.indexOf('"rating":"stutter"') >= 0) stutter++
}
return { window: "24h", total: rows.length, errors: errors, stutterReports: stutter, byEvent: byEvent }
})
const accounts = safe("accounts", () => {
const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0)
return { total: rows.length }
})
// ---- containers (Coolify) --------------------------------------------
// Degrades to configured:false rather than failing the response, so the panels
// above still render on a box where these env vars were never set.
const coolify = safe("coolify", () => {
const base = env("COOLIFY_URL")
const token = env("COOLIFY_READ_TOKEN")
if (!base || !token) {
return {
configured: false,
why: "COOLIFY_URL / COOLIFY_READ_TOKEN are not set on this container — add them " +
"in Coolify → amber-backend → Environment Variables and redeploy.",
}
}
const apps = fetchJson(base.replace(/\/+$/, "") + "/api/v1/applications",
{ Authorization: "Bearer " + token }, 8)
// Only Amber's own services; the same instance also hosts unrelated apps.
const mine = [
"amber-backend", "amber-api", "stremio_prehrajto", "Stremio_Torbox",
"adult-stremio-addon", "myanime-pair-server", "stripchat-proxy",
]
const out = []
for (const a of apps) {
const name = a.name || ""
let keep = false
for (const m of mine) if (name.indexOf(m) === 0) keep = true
if (!keep) continue
out.push({
name: name,
uuid: a.uuid,
status: a.status || "?",
fqdn: a.fqdn || null,
// sync.petruzalekr.cz is kSyncServerBase — Amber-critical despite the
// legacy repo name. Flagged so nobody decommissions it as old myAnime.
note: name.indexOf("myanime-pair-server") === 0 ? "kSyncServerBase — Amber-critical" : null,
})
}
out.sort((x, y) => (x.name < y.name ? -1 : 1))
return { configured: true, apps: out }
})
// ---- amber-api vitals -----------------------------------------------
const amberApi = safe("amberApi", () => {
const base = env("AMBER_API_URL")
const secret = env("AMBER_ADMIN_SECRET")
if (!base || !secret) {
return {
configured: false,
why: "AMBER_API_URL / AMBER_ADMIN_SECRET are not set on this container. " +
"ADMIN_SECRET is the operator role (x-amber-admin), NOT one of the " +
"SERVICE_SECRETS pairs, which grant an addon's identity.",
}
}
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,
// 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, {
generatedAt: new DateTime().string(),
releases: releases,
clients: clients,
accounts: accounts,
coolify: coolify,
amberApi: amberApi,
})
}, $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"
}
const days_ = {}, byVersion = {}, byPlatform = {}, 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)
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),
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
// returns live container stdout. An earlier design assumed this needed SSH to the
// VPS; it does not, which is how the cinema-rip bug was found in amber-api's log.
routerAdd("GET", "/api/status/logs", (e) => {
const app = e.request.url.query().get("app")
if (!app || !/^[a-z0-9]{10,40}$/.test(app)) {
return e.json(400, { error: "app must be a Coolify application uuid" })
}
let lines = parseInt(e.request.url.query().get("lines") || "200", 10)
if (!(lines > 0) || lines > 1000) lines = 200
const base = $os.getenv("COOLIFY_URL")
const token = $os.getenv("COOLIFY_READ_TOKEN")
if (!base || !token) {
return e.json(503, { error: "COOLIFY_URL / COOLIFY_READ_TOKEN not set on this container" })
}
try {
const res = $http.send({
url: base.replace(/\/+$/, "") + "/api/v1/applications/" + app + "/logs?lines=" + lines,
method: "GET",
headers: { Authorization: "Bearer " + token },
timeout: 15,
})
if (res.statusCode < 200 || res.statusCode >= 300) {
return e.json(502, { error: "coolify HTTP " + res.statusCode })
}
return e.json(200, { app: app, lines: lines, logs: (res.json && res.json.logs) || "" })
} catch (err) {
return e.json(502, { error: String(err && err.message ? err.message : err) })
}
}, $apis.requireSuperuserAuth())