diff --git a/pb_hooks/status.pb.js b/pb_hooks/status.pb.js new file mode 100644 index 0000000..2955a99 --- /dev/null +++ b/pb_hooks/status.pb.js @@ -0,0 +1,224 @@ +/// + +// Operator status dashboard — the data behind pb_public/status.html. +// +// GET /api/status (superuser) → one JSON snapshot +// GET /api/status/logs?app=&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//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()) diff --git a/pb_public/status.html b/pb_public/status.html new file mode 100644 index 0000000..dbdcbec --- /dev/null +++ b/pb_public/status.html @@ -0,0 +1,287 @@ + + + + + + +Amber — stav služeb + + + + + + + + + + +