amber-backend/pb_hooks/status.pb.js

225 lines
9.2 KiB
JavaScript
Raw Normal View History

Operator status dashboard at /status.html Answering "is Amber healthy?" meant opening Coolify, clicking 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 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. pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both superuser-gated. Superuser rather than a user flag because there is no operator field on `users` and inventing one needs a migration; nsfwEnabled was considered and rejected, since using a content flag as an admin flag would let any adult-enabled family member read infrastructure logs. **This is the first hook in the project to make an outbound HTTP request** -- every prior one read only PocketBase's own database. So each external call is wrapped to degrade one panel instead of failing the response, and carries a short timeout so a hung upstream cannot pin a worker. The Coolify token never reaches the browser; that is the entire reason the endpoint exists rather than the page calling Coolify directly. Runtime container logs come from the Coolify API, not SSH. An earlier design assumed otherwise and asked for VPS root; it is not needed. Validated against the project's own pinned PocketBase 0.39.6 before committing, because a broken pb_hooks file crashes PB at boot and would take family logins with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status executes every panel, the degradation path reports actionable "not configured" messages, and with real credentials it enumerates all seven Amber services and returns live amber-api vitals. The page was driven in a browser -- login, panels, and a log fetch returning 1812 chars of real container output. Two harness bugs found on the way, both mine: `--publicDir` does not exist in 0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 19:17:05 +00:00
/// <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.
//
// 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.
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+variant. Six rows in practice; a 200-row window
// is plenty and bounds the query regardless of history size.
const rows = $app.findRecordsByFilter("releases", "id != ''", "-buildNumber", 200, 0)
const newest = {}
for (const r of rows) {
const key = r.getString("platform") + ":" + r.getString("variant")
if (newest[key]) continue
newest[key] = {
platform: r.getString("platform"),
variant: r.getString("variant"),
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)
let adult = 0
for (const r of rows) if (r.getBool("nsfwEnabled")) adult++
return { total: rows.length, adult: adult, clean: rows.length - adult }
})
// ---- 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_TOKEN")
if (!base || !token) {
return {
configured: false,
why: "COOLIFY_URL / COOLIFY_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 }
})
return e.json(200, {
generatedAt: new DateTime().string(),
releases: releases,
clients: clients,
accounts: accounts,
coolify: coolify,
amberApi: amberApi,
})
}, $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_TOKEN")
if (!base || !token) {
return e.json(503, { error: "COOLIFY_URL / COOLIFY_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())