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
+
+
+
+
+
+
+
Amber — stav služeb
+
+ Přihlaš se superuživatelem PocketBase. Tenhle přehled je jen pro správu.
+