amber-backend/pb_public/status.html

974 lines
48 KiB
HTML
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
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Amber — stav služeb</title>
<style>
/* Amber's palette, matching the other pb_public pages. */
:root{
--bg:#0e0f13; --panel:#191b21; --panel2:#14161b; --line:#2a2d36;
--text:#f2e9d8; --muted:#9aa0aa; --accent:#f0a63c; --accent2:#c9791b;
--good:#5fd08a; --bad:#ff6b6b; --warn:#f0a63c; --goldbg:#1b1206;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
font-size:15px;line-height:1.5}
header{position:sticky;top:0;z-index:5;background:rgba(14,15,19,.94);
border-bottom:1px solid var(--line);padding:14px 16px;
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
h1{margin:0;font-size:17px;letter-spacing:.02em}
h1 span{color:var(--accent)}
.grow{flex:1}
.stamp{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
main{padding:16px;max-width:1100px;margin:0 auto;
display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
section{background:var(--panel);border:1px solid var(--line);border-radius:12px;
padding:14px 16px;min-width:0}
section.wide{grid-column:1/-1}
h2{margin:0 0 10px;font-size:11.5px;letter-spacing:.10em;text-transform:uppercase;
color:var(--muted);font-weight:600}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--panel2)}
th{color:var(--muted);font-weight:500;font-size:11.5px;text-transform:uppercase;
letter-spacing:.06em}
tr:last-child td{border-bottom:none}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
code,.mono{font-family:var(--mono);font-size:12.5px}
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11.5px;
font-weight:600;white-space:nowrap}
.ok{background:rgba(95,208,138,.14);color:var(--good)}
.un{background:rgba(154,160,170,.16);color:var(--muted)}
.no{background:rgba(255,107,107,.16);color:var(--bad)}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
.wn{background:rgba(240,166,60,.16);color:var(--warn)}
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
.big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums}
.kv{display:flex;gap:18px;flex-wrap:wrap}
.kv div{min-width:78px}
.kv .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em}
button{background:var(--panel2);color:var(--text);border:1px solid var(--line);
border-radius:8px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer}
button:hover{border-color:var(--accent2)}
button.primary{background:var(--accent);color:var(--goldbg);border-color:var(--accent);font-weight:600}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
input,select{background:var(--panel2);color:var(--text);border:1px solid var(--line);
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
border-radius:8px;padding:9px 11px;font:inherit;width:100%}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
select{padding:7px 9px;width:auto;font-size:13px}
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
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
.warnbox{background:rgba(240,166,60,.10);border:1px solid rgba(240,166,60,.35);
border-radius:8px;padding:10px 12px;color:var(--text);font-size:13px;margin-top:8px}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
.okbox{background:rgba(95,208,138,.10);border:1px solid rgba(95,208,138,.35);
border-radius:8px;padding:10px 12px;font-size:13px;margin-top:8px}
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
pre{margin:8px 0 0;background:#0b0c10;border:1px solid var(--line);border-radius:8px;
padding:10px;max-height:340px;overflow:auto;font-family:var(--mono);font-size:11.5px;
white-space:pre-wrap;word-break:break-word}
.login{max-width:340px;margin:14vh auto;padding:0 16px}
.login section{padding:18px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.svc{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--panel2)}
.svc:last-child{border-bottom:none}
.svc .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.svc a{color:var(--muted);font-size:12px;text-decoration:none}
.svc a:hover{color:var(--accent)}
.tag{color:var(--accent);font-size:11px;white-space:nowrap}
.chart{width:100%;height:150px;display:block;margin:4px 0 2px}
.chart .grid{stroke:var(--line);stroke-width:1}
.chart .tot{fill:var(--line)}
.chart .err{fill:var(--bad)}
.chart text{fill:var(--muted);font-size:9px;font-family:var(--mono)}
.legend{display:flex;gap:14px;font-size:11.5px;color:var(--muted);margin-top:2px}
.legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}
.sub{margin-top:14px}
.sub h3{margin:0 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);font-weight:600}
.rate{font-weight:600}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
/* ── the three views ─────────────────────────────────────────────────────── */
/* A segmented control rather than links: this is one page with one token, and
a reload would drop the sessionStorage-only session on some browsers. */
.tabs{display:flex;gap:2px;background:var(--panel2);border:1px solid var(--line);
border-radius:9px;padding:2px}
.tabs button{border:none;background:transparent;border-radius:7px;padding:6px 12px;
color:var(--muted);font-size:13px}
.tabs button[aria-current="true"]{background:var(--accent);color:var(--goldbg);font-weight:600}
.tabs button:hover{border-color:transparent;color:var(--text)}
.tabs button[aria-current="true"]:hover{color:var(--goldbg)}
.ctl{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.ctl label{color:var(--muted);font-size:12.5px;display:flex;align-items:center;gap:6px}
.ctl input[type=checkbox]{width:auto;padding:0;accent-color:var(--accent)}
/* A report is a card, not a table row: it carries fifteen numbers and the point
is to read one incident, not to scan a column. */
.rep{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px;
background:var(--panel2)}
.rep.test{opacity:.62}
.rep .hd{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:8px}
.rep .who{font-weight:600}
.rep .when{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
.rep .box{color:var(--muted);font-size:12px;font-family:var(--mono)}
.facts{display:grid;gap:6px 18px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
font-size:13px}
.facts .f{display:flex;gap:6px;justify-content:space-between;
border-bottom:1px solid rgba(42,45,54,.6);padding-bottom:3px}
.facts .k{color:var(--muted);font-size:12px}
.facts .v{font-family:var(--mono);font-size:12.5px;text-align:right}
.facts .v.hi{color:var(--warn);font-weight:600}
.facts .v.lo{color:var(--muted)}
.facts .v.bad{color:var(--bad);font-weight:600}
details.raw{margin-top:8px}
details.raw summary{color:var(--muted);font-size:12px;cursor:pointer}
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
/* Three across on a wide screen, one on a phone. A 420px column inside a
full-width panel left two thirds of the row empty. */
.form{display:grid;gap:10px 18px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.form .span{grid-column:1/-1}
.form button{justify-self:start;padding:9px 18px}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
.form .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:3px}
/* The address takes the slack; nothing else may wrap. A wrapped date reads as
two dates, and a wrapped button reads as two buttons. */
.accts td:first-child{word-break:break-all}
.accts td:nth-child(2),.accts td:nth-child(3),.accts td:nth-child(4){white-space:nowrap}
.accts td:last-child{white-space:nowrap;text-align:right}
.accts button{white-space:nowrap}
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
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
</style>
</head>
<body>
<div id="login" class="login" hidden>
<section>
<h2>Amber — stav služeb</h2>
<p class="note" style="margin-bottom:12px">
Přihlaš se superuživatelem PocketBase. Tenhle přehled je jen pro správu.
</p>
<div style="display:grid;gap:8px">
<input id="email" type="email" placeholder="e-mail" autocomplete="username">
<input id="pass" type="password" placeholder="heslo" autocomplete="current-password">
<button class="primary" id="loginBtn">Přihlásit</button>
</div>
<p class="note" id="loginErr" style="color:var(--bad)"></p>
</section>
</div>
<div id="app" hidden>
<header>
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
<h1>Amber <span>·</span> stav</h1>
<div class="tabs" id="tabs">
<button data-view="overview">Přehled</button>
<button data-view="reports">Hlášení</button>
<button data-view="admin">Správa</button>
</div>
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
<div class="grow"></div>
<span class="stamp" id="stamp"></span>
<button id="refresh">Obnovit</button>
<button id="logout">Odhlásit</button>
</header>
<main id="main"></main>
</div>
<script>
// Superuser token lives in sessionStorage only: it dies with the tab, and this
// page is the one place it is used. The COOLIFY and amber-api secrets never come
// near the browser -- that is the whole reason /api/status exists server-side.
var KEY = "amber.status.token";
var tok = sessionStorage.getItem(KEY) || "";
var timer = null;
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
// View + filters survive a refresh; the token deliberately does not.
var PREF = "amber.status.prefs";
var prefs = { view: "overview", days: 14, showTests: false };
try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREF) || "{}")); } catch (_) {}
function savePrefs(){ try { localStorage.setItem(PREF, JSON.stringify(prefs)); } catch (_) {} }
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
function el(id){ return document.getElementById(id); }
function esc(s){ return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); }
function bytes(n){
if (!n) return "—";
var u = ["B","kB","MB","GB"], i = 0, v = Number(n);
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return v.toFixed(i ? 1 : 0) + " " + u[i];
}
// Coolify reports "running:healthy" / "running:unknown" / "exited:unhealthy".
// "unknown" means no healthcheck is configured, NOT that something is wrong --
// worth distinguishing, or every glance looks like a half-broken system.
function statusPill(s){
var t = String(s || "?");
if (t.indexOf("running") === 0) {
return t.indexOf("healthy") > 0
? '<span class="pill ok">běží</span>'
: '<span class="pill un" title="běží, ale bez healthchecku">běží</span>';
}
return '<span class="pill no">' + esc(t) + "</span>";
}
async function api(path){
var r = await fetch(path, { headers: { Authorization: tok } });
if (r.status === 401) { logout(); throw new Error("401"); }
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
// Collection reads go straight to the PocketBase REST API with the superuser
// token the page already holds. No new server route: /api/status exists because
// the Coolify and amber-api SECRETS must not reach the browser, which does not
// apply to our own collections.
async function records(coll, params){
var q = Object.keys(params).map(function(k){
return k + "=" + encodeURIComponent(params[k]); }).join("&");
return api("/api/collections/" + coll + "/records?" + q);
}
The code has to choose the template, not the clock Sharing a second set of paid accounts with friends means a second template, and the route was not ready for one. It checked the code and then took the most recently updated row: findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) With a single row that is invisible. With two it hands the family's credentials to friends, or the reverse, decided purely by which row was edited last, silently and with no error. status.html carried a comment warning about exactly this: "never create a second". So the code now selects the row, bound as a filter parameter rather than concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row flagged isDefault, which means nobody halfway through setup broke and Coolify needed no edit; once the family row has its own code the env var stops mattering. A wrong code and a code with no row behind it give the same 403, since telling them apart would confirm which codes exist. Codes live on the rows rather than in more env vars, so a new group is one row in the admin UI instead of a redeploy, and each code is revocable on its own. The unique index is partial because PocketBase text fields default to '' and SQLite calls two empty strings equal. status.html read the template in three places, all by recency. They now go through familyTemplate(), which selects on isDefault, so adding a friends row cannot make the editor wander onto it or make account creation preload the wrong group. Its one-click preload stays family-only; a group picker there is left undone rather than half-built. The website says "rodinný kód" in four places and friends are not family, so that copy widens. The setup flow also offers "Mám kód" up front now: someone Richard shares accounts with has nothing to buy, and walking them through three price lists first would be actively misleading. Verified against PocketBase 0.39.6 with two rows and friends as the most recently updated, the state that used to break: each code resolved to its own credentials, the legacy env code resolved to family via isDefault, wrong and empty codes gave 403, a duplicate code was refused by the index, and familyTemplate() returned family while -updated returned friends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
/* The FAMILY template row, chosen by its flag rather than by being newest.
There are several rows now, one per group the owner shares accounts with, and
"-updated" would mean this page silently followed whichever group was edited
last: you would open the editor to fix a family URL and overwrite the friends'
credentials with it, or create a friend's account and preload the family's. The
page had a comment warning about exactly this ("never create a second").
This page stays the FAMILY editor. Other groups are maintained in the PocketBase
admin UI, and their members type their own code on the account page. */
async function familyTemplate(){
var r = await records("onboarding_template",
{ perPage: 1, sort: "-updated", filter: "isDefault = true" });
var row = (r.items || [])[0];
if (row) return row;
/* A database with no flagged row yet: the single row this page has always
edited. The migration flags the newest one, so this only fires on an instance
that has never had a template at all. */
var any = await records("onboarding_template", { perPage: 1, sort: "-updated" });
return (any.items || [])[0] || null;
}
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
function logout(){
sessionStorage.removeItem(KEY); tok = "";
if (timer) { clearInterval(timer); timer = null; }
el("app").hidden = true; el("login").hidden = false;
}
el("loginBtn").onclick = async function(){
el("loginErr").textContent = "";
try {
var r = await fetch("/api/collections/_superusers/auth-with-password", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: el("email").value, password: el("pass").value }),
});
if (!r.ok) throw new Error("přihlášení selhalo");
tok = (await r.json()).token;
sessionStorage.setItem(KEY, tok);
el("pass").value = "";
start();
} catch (e) { el("loginErr").textContent = e.message; }
};
el("logout").onclick = logout;
el("refresh").onclick = function(){ load(); };
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
Array.prototype.forEach.call(el("tabs").children, function(b){
b.onclick = function(){ prefs.view = b.getAttribute("data-view"); savePrefs(); syncTabs(); load(); };
});
function syncTabs(){
Array.prototype.forEach.call(el("tabs").children, function(b){
b.setAttribute("aria-current", b.getAttribute("data-view") === prefs.view ? "true" : "false");
});
}
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
function panelServices(c){
if (!c || c.configured === false) {
return '<section><h2>Služby</h2><div class="warnbox">' + esc(c && c.why || "nenastaveno") +
"</div></section>";
}
if (c.error) {
return '<section><h2>Služby</h2><div class="warnbox">Coolify neodpovědělo: ' +
esc(c.error) + "</div></section>";
}
var rows = (c.apps || []).map(function(a){
return '<div class="svc">' + statusPill(a.status) +
'<span class="nm">' + esc(a.name) + "</span>" +
(a.note ? '<span class="tag">' + esc(a.note) + "</span>" : "") +
(a.fqdn ? '<a href="' + esc(a.fqdn.split(",")[0]) + '" target="_blank" rel="noopener">otevřít</a>' : "") +
'<button data-log="' + esc(a.uuid) + '">log</button></div>';
}).join("");
return '<section class="wide"><h2>Služby</h2>' + (rows || '<p class="note">nic</p>') +
'<div id="logbox"></div></section>';
}
function panelReleases(rs){
if (!rs || rs.error) return '<section><h2>Vydání</h2><p class="note">' +
esc(rs && rs.error || "—") + "</p></section>";
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
var body = rs.map(function(r){
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
"</td><td class='mono'>" + esc(r.version) + "+" + r.build +
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
}).join("");
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
body + "</table>" +
'<p class="note">Buildy se nemusí shodovat mezi platformami — když se vydává jen ' +
"pro počítače, televize zůstane na starším čísle a nedostane výzvu k aktualizaci." +
"</p></section>";
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
}
function panelClients(c){
if (!c || c.error) return '<section><h2>Klienti</h2><p class="note">' +
esc(c && c.error || "—") + "</p></section>";
var ev = Object.keys(c.byEvent || {}).sort(function(a, b){ return c.byEvent[b] - c.byEvent[a]; })
.slice(0, 6).map(function(k){
return "<tr><td>" + esc(k) + "</td><td class='num'>" + c.byEvent[k] + "</td></tr>"; }).join("");
return '<section><h2>Klienti — 24 h</h2><div class="kv">' +
'<div><div class="lbl">záznamů</div><div class="big">' + c.total + "</div></div>" +
'<div><div class="lbl">chyb</div><div class="big" style="color:' +
(c.errors ? "var(--bad)" : "var(--good)") + '">' + c.errors + "</div></div>" +
'<div><div class="lbl">„sekalo se"</div><div class="big" style="color:' +
(c.stutterReports ? "var(--warn)" : "var(--muted)") + '">' + c.stutterReports + "</div></div>" +
"</div>" + (ev ? "<table style='margin-top:10px'>" + ev + "</table>" : "") +
'<p class="note">Hodnocení „sekalo se" při nulových stallech je slepé místo, ' +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
"které počítadla nevidí — proto je zvlášť. Podrobnosti jsou v <b>Hlášení</b>." +
"</p></section>";
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
}
function panelApi(a){
if (!a || a.configured === false) {
return '<section><h2>amber-api</h2><div class="warnbox">' +
esc(a && a.why || "nenastaveno") + "</div></section>";
}
if (a.error) return '<section><h2>amber-api</h2><div class="warnbox">' +
esc(a.error) + "</div></section>";
var p = a.probe || {}, q = a.queue || {}, m = a.metadata || {}, h = a.health || {};
return '<section><h2>amber-api</h2><div class="kv">' +
'<div><div class="lbl">hotové sondy</div><div class="big">' + (p.ready || 0) + "</div></div>" +
'<div><div class="lbl">ve frontě</div><div class="big">' + (q.queued || 0) + "</div></div>" +
'<div><div class="lbl">selhalo</div><div class="big" style="color:' +
((p.failed || p.permafail) ? "var(--warn)" : "var(--muted)") + '">' +
((p.failed || 0) + (p.permafail || 0)) + "</div></div>" +
"</div><table style='margin-top:10px'>" +
"<tr><td>metadata (řádků / zásahů)</td><td class='num'>" + (m.rows || 0) + " / " + (m.hits || 0) + "</td></tr>" +
"<tr><td>zdraví zdrojů (klíčů)</td><td class='num'>" + (h.scoredKeys || 0) + "</td></tr>" +
"<tr><td>hostů v ochlazení</td><td class='num'>" + (q.hostsCoolingDown || 0) + "</td></tr>" +
"</table></section>";
}
function panelAccounts(a){
if (!a || a.error) return "";
return '<section><h2>Účty</h2><div class="kv">' +
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
'</div><p class="note">Nový účet se zakládá ve <b>Správě</b>.</p></section>';
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
}
// Errors over time. Inline SVG rather than a charting library: this page is served
// by PocketBase with no build step, and 14 bars do not justify a dependency.
function panelErrors(d){
if (!d || d.error) return '<section class="wide"><h2>Chyby v čase</h2>' +
'<div class="warnbox">' + esc(d && d.error || "—") + "</div></section>";
var days = d.days || [];
var max = 1;
days.forEach(function(x){ if (x.total > max) max = x.total; });
// viewBox coordinates, so the chart scales to any width without JS on resize.
var W = 700, H = 150, PAD = 18, bw = (W - PAD * 2) / Math.max(days.length, 1);
var bars = days.map(function(x, i){
var bx = PAD + i * bw + 1, bwv = Math.max(bw - 2, 1);
var th = Math.round((x.total / max) * (H - PAD * 2));
var eh = Math.round((x.errors / max) * (H - PAD * 2));
var t = esc(x.date) + ": " + x.total + " záznamů, " + x.errors + " chyb";
return '<g><title>' + t + "</title>" +
'<rect class="tot" x="' + bx + '" y="' + (H - PAD - th) + '" width="' + bwv +
'" height="' + th + '" rx="1"/>' +
(eh > 0 ? '<rect class="err" x="' + bx + '" y="' + (H - PAD - eh) + '" width="' + bwv +
'" height="' + eh + '" rx="1"/>' : "") + "</g>";
}).join("");
// Label only the ends and the middle; 14 dates do not fit on a phone.
var labels = days.length
? [0, Math.floor(days.length / 2), days.length - 1].map(function(i){
var x = PAD + i * bw + bw / 2;
var anchor = i === 0 ? "start" : (i === days.length - 1 ? "end" : "middle");
return '<text x="' + x + '" y="' + (H - 4) + '" text-anchor="' + anchor + '">' +
esc(days[i].date.substring(5)) + "</text>";
}).join("")
: "";
function tbl(title, rows, showRate){
if (!rows || !rows.length) return "";
var body = rows.map(function(r){
return "<tr><td>" + esc(r.key) + "</td><td class='num'>" + r.total +
"</td><td class='num' style='color:" + (r.errors ? "var(--bad)" : "var(--muted)") + "'>" +
r.errors + "</td>" +
(showRate ? "<td class='num rate' style='color:" +
(r.rate >= 20 ? "var(--bad)" : r.rate >= 5 ? "var(--warn)" : "var(--good)") +
"'>" + r.rate + " %</td>" : "") + "</tr>";
}).join("");
return '<div class="sub"><h3>' + esc(title) + "</h3><table>" +
"<tr><th></th><th class='num'>záznamů</th><th class='num'>chyb</th>" +
(showRate ? "<th class='num'>podíl</th>" : "") + "</tr>" + body + "</table></div>";
}
return '<section class="wide"><h2>Chyby v čase — ' + d.window + ' dní</h2>' +
'<svg class="chart" viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" ' +
'role="img" aria-label="chyby za posledních ' + d.window + ' dní">' +
'<line class="grid" x1="' + PAD + '" y1="' + (H - PAD) + '" x2="' + (W - PAD) +
'" y2="' + (H - PAD) + '"/>' + bars + labels + "</svg>" +
'<div class="legend"><span><i class="tot" style="background:var(--line)"></i>záznamy</span>' +
'<span><i style="background:var(--bad)"></i>chyby</span>' +
'<span style="margin-left:auto">vzorek ' + d.sampled +
(d.truncated ? " (zkráceno)" : "") + "</span></div>" +
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
tbl("Podle systému", d.byPlatform, true) +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
tbl("Podle varianty", d.byFlavour, true) +
tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){
return { key: x.key, total: x.errors, errors: x.errors }; }), false) +
'<p class="note">Historie je omezená retencí: client_logs se každou noc čistí ' +
"na 14 dní, starší data neexistují.</p></section>";
}
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
async function showLog(uuid, btn){
var box = el("logbox");
box.innerHTML = '<p class="note">načítám…</p>';
try {
var d = await api("/api/status/logs?app=" + encodeURIComponent(uuid) + "&lines=250");
var txt = (d.logs || "").trim() || "(prázdné)";
box.innerHTML = "<pre>" + esc(txt) + "</pre>";
box.querySelector("pre").scrollTop = box.querySelector("pre").scrollHeight;
} catch (e) {
box.innerHTML = '<div class="warnbox">log se nepodařilo načíst: ' + esc(e.message) + "</div>";
}
}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
/* ══ Hlášení ═══════════════════════════════════════════════════════════════════
One incident per card, with the counters that were clean beside the ones that
were not. This exists because a real report used to arrive as
`user: 1ve1qbjb...` on `device: android`, which says neither who is unhappy nor
which of the four televisions -- and could not be told apart from our own test
runs at all.
`tester` marks our sessions and they are HIDDEN by default: the question this
view answers is what the family experienced. */
function isTester(r){
if (r.tester === true) return true;
// Rows written before the `tester` column existed, and any future client that
// omits it, fall back to the emulator tell. Absent-and-old counts as the
// family's, which over-reports rather than hiding a real complaint.
return String(r.device || "").indexOf("(emulator)") >= 0;
}
function who(r){
var u = (r.expand && r.expand.user) || null;
return u ? (u.email || u.username || u.id) : (r.user ? r.user : "anonym");
}
// A fact is dimmed when it carries no information, highlighted when it does. The
// judgement is in one place so a card can be read at a glance instead of parsed.
function fact(k, v, cls){
if (v === undefined || v === null || v === "") {
return '<div class="f"><span class="k">' + esc(k) +
'</span><span class="v lo">neměří se</span></div>';
}
return '<div class="f"><span class="k">' + esc(k) + '</span><span class="v' +
(cls ? " " + cls : "") + '">' + esc(v) + "</span></div>";
}
function reportCard(r){
var m = r.meta || {};
var stutter = m.rating === "stutter";
var reports = m.problemAtS;
var mean = m.frameOffsetCount
? Math.round(m.frameOffsetUsTotal / m.frameOffsetCount / 1000) + " ms" : null;
var verdict = stutter
? '<span class="pill wn">sekalo se</span>'
: (m.rating === "good" ? '<span class="pill ok">bylo dobré</span>' : "");
var head = '<div class="hd">' + verdict +
'<span class="who">' + esc(who(r)) + "</span>" +
'<span class="when">' + esc(String(r.created).substring(0, 16)) + "</span>" +
'<div class="grow"></div>' +
'<span class="box">' + esc(r.device || "?") + "</span>" +
'<span class="box">' + esc(r.appVersion || "?") +
(r.gitSha ? " · " + esc(r.gitSha) : "") + "</span>" +
(isTester(r) ? '<span class="pill un">náš test</span>' : "") + "</div>";
// "zhlédnuto", and a space before the note: rendered output read
// "Pravá a levá ruka ďáblashlédnuto 1278 s", which is the kind of thing that is
// invisible in the source and obvious in one frame.
var title = '<div style="margin-bottom:8px"><b>' + esc(m.title || "?") + "</b> " +
'<span class="note" style="margin-left:8px">' +
"zhlédnuto " + (m.watchedS || 0) + " s" +
(m.durationS ? " z " + m.durationS + " s" : "") +
(reports ? " · tlačítko „hlásit“ v " + esc(JSON.stringify(reports)) + " s" : "") +
"</span></div>";
// Grouped by the question each answers, because the whole point is that the
// first group can be clean while the viewer saw the picture hitch.
var counters = '<div class="facts">' +
fact("stally (buffering)", m.stalls, m.stalls > 0 ? "hi" : null) +
fact("zahozené snímky", m.droppedFrames, m.droppedFrames > 0 ? "hi" : null) +
fact("nejdelší série", m.maxConsecutiveDropped, m.maxConsecutiveDropped > 0 ? "hi" : null) +
fact("odchylka snímků", mean) +
"</div>";
var newer = '<div class="facts" style="margin-top:8px">' +
fact("blokující GC", m.gcBlockingCount === undefined ? undefined
: m.gcBlockingCount + "× / " + (m.gcBlockingMs || 0) + " ms",
m.gcBlockingCount > 0 ? "hi" : null) +
fact("chyby načtení", m.loadErrors === undefined ? undefined
: m.loadErrors + (m.lastLoadError ? " (" + m.lastLoadError + ")" : ""),
m.loadErrors > 0 ? "bad" : null) +
fact("přenos", m.throughputKbps > 0
? Math.round(m.throughputKbps / 1000) + " Mb/s (min " +
Math.round((m.throughputMinKbps || 0) / 1000) + ")" : undefined) +
fact("buffer nejméně", m.demuxerCacheMinS === undefined ? undefined
: m.demuxerCacheMinS + " s", m.demuxerCacheMinS < 3 ? "hi" : null) +
fact("snímků/s", m.fpsRendered) +
fact("panel", m.displayRefreshHz ? m.displayRefreshHz + " Hz" : undefined) +
fact("dekodér", m.videoDecoder) +
fact("paměť", m.heapMaxMb ? m.heapUsedMb + " / " + m.heapMaxMb + " MB" : undefined) +
"</div>";
var file = '<p class="note">' +
esc([m.videoHeight ? m.videoHeight + "p" : null,
(m.videoCodec || "").replace("video/", ""),
(m.audioCodec || "").replace("audio/", ""),
m.audioChannels ? m.audioChannels + "ch" : null,
m.audioLang,
m.forcedAudio ? "vynucený dabing" : null,
m.host ? String(m.host).replace(/^https?:\/\//, "") : null,
].filter(Boolean).join(" · ")) + "</p>";
return '<div class="rep' + (isTester(r) ? " test" : "") + '">' + head + title +
counters + newer + file +
'<details class="raw"><summary>všechna pole</summary><pre>' +
esc(JSON.stringify(m, null, 1)) + "</pre></details></div>";
}
function viewReports(rows){
var reps = rows.filter(function(r){
var m = r.meta || {};
return m.rating === "stutter" || (m.problemAtS && m.problemAtS.length);
});
var shown = prefs.showTests ? reps : reps.filter(function(r){ return !isTester(r); });
var ctl = '<div class="ctl">' +
'<label>období <select id="days">' +
[1, 3, 7, 14].map(function(d){
return '<option value="' + d + '"' + (prefs.days === d ? " selected" : "") +
">" + d + " dní</option>"; }).join("") + "</select></label>" +
'<label><input type="checkbox" id="showTests"' + (prefs.showTests ? " checked" : "") +
"> včetně našich testů</label>" +
'<div class="grow"></div><span class="note" style="margin:0">' +
shown.length + " z " + reps.length + " hlášení</span></div>";
var body = shown.length
? shown.map(reportCard).join("")
: '<div class="okbox">Za tohle období si nikdo nestěžoval.</div>';
return '<section class="wide"><h2>Hlášení — co hlásila rodina</h2>' + ctl + body +
'<p class="note">„Neměří se“ znamená, že to tenhle přehrávač neposílá — ' +
"televize (ExoPlayer) posílá všechno, počítač (mpv) jen část. Je to jiné " +
"tvrzení než nula.</p></section>";
}
function viewDevices(rows){
// Group by box, because "which television" was unanswerable until the device
// label carried a model and a per-install id.
var by = {};
rows.forEach(function(r){
var k = r.device || "?";
if (!by[k]) by[k] = { n: 0, tester: isTester(r), who: {}, ver: {}, last: "" };
var g = by[k];
g.n++;
g.who[who(r)] = true;
g.ver[(r.appVersion || "?") + (r.gitSha ? " · " + r.gitSha : "")] = true;
if (r.created > g.last) g.last = r.created;
});
var keys = Object.keys(by).sort(function(a, b){ return by[b].n - by[a].n; });
if (!keys.length) return "";
var body = keys.map(function(k){
var g = by[k];
return "<tr><td class='mono'>" + esc(k) +
(g.tester ? ' <span class="pill un">náš test</span>' : "") + "</td>" +
"<td>" + esc(Object.keys(g.who).join(", ")) + "</td>" +
"<td class='mono'>" + esc(Object.keys(g.ver).join(", ")) + "</td>" +
"<td class='num'>" + g.n + "</td>" +
"<td class='mono'>" + esc(g.last.substring(0, 16)) + "</td></tr>";
}).join("");
return '<section class="wide"><h2>Zařízení — kde se kouká</h2><table>' +
"<tr><th>krabice</th><th>kdo</th><th>build</th><th class='num'>sezení</th>" +
"<th>naposled</th></tr>" + body + "</table>" +
'<p class="note">Identifikátor za tečkou je náhodný a jen místní — říká ' +
"„je to pořád ta samá krabice“, nic víc. Po reinstalaci se změní.</p></section>";
}
/* ══ Správa ════════════════════════════════════════════════════════════════════
Creating an account is a plain POST to the users collection with the superuser
token this page already holds. Registration stays CLOSED -- there is no public
signup and no invite code behind this; `users.createRule` is null, so only a
superuser can do it, which is exactly who is looking at this screen. */
function viewAdmin(users){
var rows = (users || []).map(function(u){
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
"<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>'
: '<span class="pill ok">clean</span>') + "</td>" +
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
'<td><button data-nsfw="' + esc(u.id) + '" data-to="' +
(u.nsfwEnabled ? "0" : "1") + '">' +
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
}).join("");
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
return '<section class="wide"><h2>Nový účet</h2>' +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
'<div class="form">' +
'<div><div class="lbl">e-mail</div><input id="nEmail" type="email" ' +
'autocomplete="off" placeholder="jmeno@example.com"></div>' +
'<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' +
'<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
'placeholder="nejméně 8 znaků"></div>' +
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
'<label class="row span" style="color:var(--muted);font-size:13px">' +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
"povolit 18+ (jinak dostane clean build)</label>" +
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nFamily" checked ' +
'style="width:auto;accent-color:var(--accent)"> ' +
"použít rodinné nastavení — zdroje i TMDB klíč nastavím hned, " +
"nemusí zadávat rodinný kód</label>" +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
'<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' +
'<option value="">bez omezení</option><option value="kids">kids</option>' +
'<option value="twelve">12+</option><option value="fifteen">15+</option>' +
"</select></div>" +
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
'<div class="span"><button class="primary" id="createBtn">Založit účet</button></div>' +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
"</div>" +
'<div id="createOut"></div>' +
'<p class="note">Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' +
"musíš mu ho předat. Nikam se neukládá, po založení zmizí.</p>" +
'<p class="note">Registrace je zavřená: veřejné zakládání účtů neexistuje a ' +
"tohle jde přes superuživatele, kterým jsi právě přihlášený.</p></section>" +
'<section class="wide"><h2>Účty</h2><table class="accts">' +
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
"<tr><th>e-mail</th><th>varianta</th><th>strop</th><th>vznik</th><th></th></tr>" +
(rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" +
'<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' +
"aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " +
"vyžádat.</p></section>";
}
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
/* ── Rodinné nastavení (onboarding_template) ──────────────────────────────────
The shared config a new family member's first setup pulls down, unlocked by
AMBER_INVITE_CODE. One record, superuser-only on every rule, so the dashboard
reads and writes it directly.
These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login
and a TorBox key. They are masked until you ask to see them, and nothing here
logs or echoes them. That is also why this lives behind a superuser login on a
noindex page and nowhere else. */
function viewTemplate(tpl){
var v = tpl || {};
return '<section class="wide"><h2>Rodinné nastavení — co dostane nový člen</h2>' +
'<div class="form">' +
'<div class="span"><div class="lbl">TorBox doplněk (addonUrl)</div>' +
'<input id="tAddon" type="password" autocomplete="off" value="' +
esc(v.addonUrl || "") + '"></div>' +
'<div class="span"><div class="lbl">Český doplněk (czechAddonUrl)</div>' +
'<input id="tCzech" type="password" autocomplete="off" value="' +
esc(v.czechAddonUrl || "") + '"></div>' +
'<div><div class="lbl">TMDB klíč</div><input id="tTmdb" type="password" ' +
'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' +
'<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' +
'autocomplete="off" value="' + esc(v.note || "") + '"></div>' +
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' +
"zobrazit hodnoty</label>" +
'<div class="span"><button class="primary" id="tSave">Uložit nastavení</button>' +
(v.updated ? '<span class="note" style="margin-left:12px">naposledy ' +
esc(String(v.updated).substring(0, 16)) + "</span>" : "") + "</div>" +
"</div><div id=\"tOut\"></div>" +
'<p class="note">V těchhle adresách jsou přihlašovací údaje k prehraj.to a ' +
"klíč k TorBoxu — proto jsou schované a proto je tahle stránka jen pro tebe. " +
"Nový člen si je stáhne při prvním nastavení pomocí rodinného kódu.</p>" +
The code has to choose the template, not the clock Sharing a second set of paid accounts with friends means a second template, and the route was not ready for one. It checked the code and then took the most recently updated row: findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) With a single row that is invisible. With two it hands the family's credentials to friends, or the reverse, decided purely by which row was edited last, silently and with no error. status.html carried a comment warning about exactly this: "never create a second". So the code now selects the row, bound as a filter parameter rather than concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row flagged isDefault, which means nobody halfway through setup broke and Coolify needed no edit; once the family row has its own code the env var stops mattering. A wrong code and a code with no row behind it give the same 403, since telling them apart would confirm which codes exist. Codes live on the rows rather than in more env vars, so a new group is one row in the admin UI instead of a redeploy, and each code is revocable on its own. The unique index is partial because PocketBase text fields default to '' and SQLite calls two empty strings equal. status.html read the template in three places, all by recency. They now go through familyTemplate(), which selects on isDefault, so adding a friends row cannot make the editor wander onto it or make account creation preload the wrong group. Its one-click preload stays family-only; a group picker there is left undone rather than half-built. The website says "rodinný kód" in four places and friends are not family, so that copy widens. The setup flow also offers "Mám kód" up front now: someone Richard shares accounts with has nothing to buy, and walking them through three price lists first would be actively misleading. Verified against PocketBase 0.39.6 with two rows and friends as the most recently updated, the state that used to break: each code resolved to its own credentials, the legacy env code resolved to family via isDefault, wrong and empty codes gave 403, a duplicate code was refused by the index, and familyTemplate() returned family while -updated returned friends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
'<p class="note">Tohle je nastavení <b>rodiny</b>. Další skupiny (třeba přátelé) mají svoje vlastní řádky s vlastními údaji a vlastním kódem — ty se spravují v PocketBase adminu a jejich lidi si kód zadají sami na svém účtu.</p>' +
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
(tpl ? "" : '<div class="warnbox">Žádné nastavení ještě není uložené — ' +
"první uložení ho vytvoří.</div>") + "</section>";
}
async function saveTemplate(){
var out = el("tOut");
out.innerHTML = '<p class="note">ukládám…</p>';
var body = {
addonUrl: el("tAddon").value.trim(),
czechAddonUrl: el("tCzech").value.trim(),
tmdbKey: el("tTmdb").value.trim(),
note: el("tNote").value.trim(),
};
try {
The code has to choose the template, not the clock Sharing a second set of paid accounts with friends means a second template, and the route was not ready for one. It checked the code and then took the most recently updated row: findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) With a single row that is invisible. With two it hands the family's credentials to friends, or the reverse, decided purely by which row was edited last, silently and with no error. status.html carried a comment warning about exactly this: "never create a second". So the code now selects the row, bound as a filter parameter rather than concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row flagged isDefault, which means nobody halfway through setup broke and Coolify needed no edit; once the family row has its own code the env var stops mattering. A wrong code and a code with no row behind it give the same 403, since telling them apart would confirm which codes exist. Codes live on the rows rather than in more env vars, so a new group is one row in the admin UI instead of a redeploy, and each code is revocable on its own. The unique index is partial because PocketBase text fields default to '' and SQLite calls two empty strings equal. status.html read the template in three places, all by recency. They now go through familyTemplate(), which selects on isDefault, so adding a friends row cannot make the editor wander onto it or make account creation preload the wrong group. Its one-click preload stays family-only; a group picker there is left undone rather than half-built. The website says "rodinný kód" in four places and friends are not family, so that copy widens. The setup flow also offers "Mám kód" up front now: someone Richard shares accounts with has nothing to buy, and walking them through three price lists first would be actively misleading. Verified against PocketBase 0.39.6 with two rows and friends as the most recently updated, the state that used to break: each code resolved to its own credentials, the legacy env code resolved to family via isDefault, wrong and empty codes gave 403, a duplicate code was refused by the index, and familyTemplate() returned family while -updated returned friends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
// The family row specifically. Several rows exist now (one per group), so
// "whichever was updated last" would let this editor wander onto the friends'
// credentials -- see familyTemplate(). A first-ever save creates the family row
// and flags it, so both this page and the route's legacy-code fallback agree on
// which row that is.
var rec = await familyTemplate();
if (!rec) { body.isDefault = true; body.name = "family"; }
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
var r = await fetch("/api/collections/onboarding_template/records" +
(rec ? "/" + rec.id : ""), {
method: rec ? "PATCH" : "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body),
});
if (!r.ok) {
var d = await r.json();
throw new Error((d && d.message) || ("HTTP " + r.status));
}
out.innerHTML = '<div class="okbox">Uloženo. Nový člen to dostane při ' +
"prvním nastavení; už nastavené účty se tím nemění.</div>";
load();
} catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
}
}
/* ── vault crypto: MUST match the app byte-for-byte ───────────────────────────
Lifted unchanged from index.html, which is itself pinned against
addon_config_crypto.dart by addon_config_crypto_interop_test. Do not "tidy"
the iteration count, the kdf id or the blob layout — a device that already
cached a key would stop being able to open the blob, and the failure looks
like a password reset rather than a bug.
key = PBKDF2-HMAC-SHA256(password, salt, 210000, 256 bit)
blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] ) */
var PBKDF2_ITERS = 210000;
var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS;
function b64e(u8){ var s = ""; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
function b64d(s){ var b = atob(s), u = new Uint8Array(b.length);
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u; }
function concatBytes(a, b){ var o = new Uint8Array(a.length + b.length);
o.set(a, 0); o.set(b, a.length); return o; }
async function deriveVaultKey(password, salt){
var base = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
var bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: salt, iterations: PBKDF2_ITERS }, base, 256);
return crypto.subtle.importKey("raw", bits, "AES-GCM", false, ["encrypt", "decrypt"]);
}
async function sealConfig(plaintext, password, saltB64){
var key = await deriveVaultKey(password, b64d(saltB64));
var nonce = crypto.getRandomValues(new Uint8Array(12));
var ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, tagLength: 128 }, key,
new TextEncoder().encode(plaintext)));
return b64e(concatBytes(nonce, ct));
}
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
/* Pre-load the family config for an account being created RIGHT NOW.
Three steps, and each one is why this could not be done before:
1. the account has no profile yet, and `addon_config` is keyed by profile.
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
profile before creating one -- explicitly so a second device does not
make a duplicate -- so a profile made here is adopted as the default on
first sign-in rather than fought over.
2. the blob is encrypted with a key derived from the USER'S password, which
the server never has. This page has it, once, because it just chose it.
3. the template is superuser-only, so no family code is needed here.
If they later change their password the blob stops opening and the app flips
`addonConfigNeedsReentry`, which is the documented, already-handled path. */
async function preloadFamilyConfig(userId, name, password){
The code has to choose the template, not the clock Sharing a second set of paid accounts with friends means a second template, and the route was not ready for one. It checked the code and then took the most recently updated row: findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) With a single row that is invisible. With two it hands the family's credentials to friends, or the reverse, decided purely by which row was edited last, silently and with no error. status.html carried a comment warning about exactly this: "never create a second". So the code now selects the row, bound as a filter parameter rather than concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row flagged isDefault, which means nobody halfway through setup broke and Coolify needed no edit; once the family row has its own code the env var stops mattering. A wrong code and a code with no row behind it give the same 403, since telling them apart would confirm which codes exist. Codes live on the rows rather than in more env vars, so a new group is one row in the admin UI instead of a redeploy, and each code is revocable on its own. The unique index is partial because PocketBase text fields default to '' and SQLite calls two empty strings equal. status.html read the template in three places, all by recency. They now go through familyTemplate(), which selects on isDefault, so adding a friends row cannot make the editor wander onto it or make account creation preload the wrong group. Its one-click preload stays family-only; a group picker there is left undone rather than half-built. The website says "rodinný kód" in four places and friends are not family, so that copy widens. The setup flow also offers "Mám kód" up front now: someone Richard shares accounts with has nothing to buy, and walking them through three price lists first would be actively misleading. Verified against PocketBase 0.39.6 with two rows and friends as the most recently updated, the state that used to break: each code resolved to its own credentials, the legacy env code resolved to family via isDefault, wrong and empty codes gave 403, a duplicate code was refused by the index, and familyTemplate() returned family while -updated returned friends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
var tpl = await familyTemplate();
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
if (!tpl) throw new Error("rodinné nastavení není uložené");
var pr = await fetch("/api/collections/profiles/records", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify({ user: userId, name: name || "Me" }),
});
if (!pr.ok) throw new Error("profil se nepodařilo vytvořit");
var prof = await pr.json();
var cfg = {
addonUrl: tpl.addonUrl || "",
czechAddonUrl: tpl.czechAddonUrl || "",
tmdbKey: tpl.tmdbKey || "",
};
var salt = newSaltB64();
var body = {
blob: await sealConfig(JSON.stringify(cfg), password, salt),
salt: salt, kdf: KDF_ID, profile: prof.id,
updatedAt: new Date().toISOString(),
};
var cr = await fetch("/api/collections/addon_config/records", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body),
});
if (!cr.ok) throw new Error("zdroje se nepodařilo uložit");
}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
async function createAccount(){
var out = el("createOut");
var email = el("nEmail").value.trim();
var pass = el("nPass").value;
if (!email || pass.length < 8) {
out.innerHTML = '<div class="warnbox">E-mail a heslo (min. 8 znaků) jsou povinné.</div>';
return;
}
out.innerHTML = '<p class="note">zakládám…</p>';
try {
var body = {
email: email, password: pass, passwordConfirm: pass,
nsfwEnabled: el("nNsfw").checked,
// Verified up front: the family never goes through an e-mail confirmation,
// and an unverified account would just be a trap nobody could clear.
verified: true,
};
var name = el("nName").value.trim();
if (name) body.name = name;
var rating = el("nRating").value;
if (rating) body.ratingDefault = rating;
var r = await fetch("/api/collections/users/records", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body),
});
var d = await r.json();
if (!r.ok) {
// PocketBase nests the useful part per field; a bare "400" tells nobody
// whether the address is taken or the password is too short.
var why = d && d.data
? Object.keys(d.data).map(function(k){
return k + ": " + (d.data[k].message || "?"); }).join("; ")
: (d && d.message) || ("HTTP " + r.status);
throw new Error(why);
}
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
var extra = "";
if (el("nFamily").checked) {
// The account EXISTS at this point. If pre-loading fails, say so instead
// of reporting a clean success or rolling the account back — a half-made
// account you were not told about is the worst of the three.
try {
await preloadFamilyConfig(d.id, name, pass);
extra = " Rodinné zdroje jsou nastavené — po přihlášení je uvidí sám.";
} catch (e2) {
extra = ' <b style="color:var(--warn)">Účet je založený, ale rodinné ' +
"nastavení se nepodařilo přiřadit (" + esc(e2.message) +
"). Zdroje si zadá sám rodinným kódem.</b>";
}
}
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = "";
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
el("nFamily").checked = true;
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
load();
} catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
}
}
async function setNsfw(id, to){
try {
var r = await fetch("/api/collections/users/records/" + encodeURIComponent(id), {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify({ nsfwEnabled: to === "1" }),
});
if (!r.ok) throw new Error("HTTP " + r.status);
load();
} catch (e) {
el("main").insertAdjacentHTML("afterbegin",
'<section class="wide"><div class="warnbox">' + esc(e.message) + "</div></section>");
}
}
function wire(){
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
});
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-nsfw]"), function(b){
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
});
if (el("createBtn")) el("createBtn").onclick = createAccount;
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
if (el("tSave")) el("tSave").onclick = saveTemplate;
if (el("tShow")) el("tShow").onchange = function(){
var ty = el("tShow").checked ? "text" : "password";
["tAddon", "tCzech", "tTmdb"].forEach(function(id){ el(id).type = ty; });
};
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
if (el("days")) el("days").onchange = function(){
prefs.days = Number(el("days").value); savePrefs(); load(); };
if (el("showTests")) el("showTests").onchange = function(){
prefs.showTests = el("showTests").checked; savePrefs(); load(); };
}
// Sessions for the reports + devices views. `expand=user` resolves the reporter's
// address here rather than the app writing identity onto 600 telemetry rows.
async function sessions(){
var from = new Date(Date.now() - prefs.days * 864e5).toISOString()
.replace("T", " ").substring(0, 19);
var d = await records("client_logs", {
filter: "event='session_summary' && created>='" + from + "'",
sort: "-created", perPage: 400, expand: "user",
});
return d.items || [];
}
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
async function load(){
try {
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
el("stamp").textContent = "aktualizováno " + new Date().toLocaleTimeString("cs-CZ");
if (prefs.view === "admin") {
var us = await records("users", { sort: "-created", perPage: 200 });
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
var tp = null;
try {
The code has to choose the template, not the clock Sharing a second set of paid accounts with friends means a second template, and the route was not ready for one. It checked the code and then took the most recently updated row: findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) With a single row that is invisible. With two it hands the family's credentials to friends, or the reverse, decided purely by which row was edited last, silently and with no error. status.html carried a comment warning about exactly this: "never create a second". So the code now selects the row, bound as a filter parameter rather than concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row flagged isDefault, which means nobody halfway through setup broke and Coolify needed no edit; once the family row has its own code the env var stops mattering. A wrong code and a code with no row behind it give the same 403, since telling them apart would confirm which codes exist. Codes live on the rows rather than in more env vars, so a new group is one row in the admin UI instead of a redeploy, and each code is revocable on its own. The unique index is partial because PocketBase text fields default to '' and SQLite calls two empty strings equal. status.html read the template in three places, all by recency. They now go through familyTemplate(), which selects on isDefault, so adding a friends row cannot make the editor wander onto it or make account creation preload the wrong group. Its one-click preload stays family-only; a group picker there is left undone rather than half-built. The website says "rodinný kód" in four places and friends are not family, so that copy widens. The setup flow also offers "Mám kód" up front now: someone Richard shares accounts with has nothing to buy, and walking them through three price lists first would be actively misleading. Verified against PocketBase 0.39.6 with two rows and friends as the most recently updated, the state that used to break: each code resolved to its own credentials, the legacy env code resolved to family via isDefault, wrong and empty codes gave 403, a duplicate code was refused by the index, and familyTemplate() returned family while -updated returned friends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:35:46 +00:00
tp = await familyTemplate();
Dashboard: the family config, set once and handed to new accounts Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three.
2026-08-08 23:23:53 +00:00
} catch (_) { /* no template yet, or the collection is missing */ }
el("main").innerHTML = viewAdmin(us.items || []) + viewTemplate(tp);
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
wire();
return;
}
if (prefs.view === "reports") {
var rows = await sessions();
el("main").innerHTML = viewReports(rows) + viewDevices(rows);
wire();
return;
}
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
var d = await api("/api/status");
var errs = null;
try { errs = await api("/api/status/errors"); } catch (_) { errs = { error: "nedostupné" }; }
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
el("main").innerHTML =
panelServices(d.coolify) + panelReleases(d.releases) +
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) +
panelErrors(errs);
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
wire();
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
} catch (e) {
if (e.message !== "401") {
el("main").innerHTML = '<section class="wide"><div class="warnbox">' +
esc(e.message) + "</div></section>";
}
}
}
function start(){
el("login").hidden = true; el("app").hidden = false;
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
syncTabs();
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
load();
if (timer) clearInterval(timer);
Dashboard: read the reports, and create accounts without the admin UI Two views on status.html, and one migration. Hlášení: one incident per card rather than a table row, because a report carries fifteen numbers and the job is to read one incident, not scan a column. The reporter's address is resolved from the `user` relation in the browser — the app never writes identity onto telemetry rows. The box is named. Our own test sessions are HIDDEN by default, since the question this view answers is what the family experienced; `tester` OR an emulator tell OR a test address, so it stays right for rows written before the column existed. Day-range filter, and every field says "neměří se" rather than 0 when that player does not send it — a different claim from zero, and the TV sends everything while mpv sends part. Správa: create an account and flip clean/18+. Registration stays CLOSED — `users.createRule` is still null and this is a plain POST with the superuser token the page has held since it was written, so it grants nothing that was not already available in the PocketBase admin UI. The password field is deliberately visible: you are making it for someone else and have to read it out. client_logs.gitSha: which BUILD wrote a row, not which version. Fourth outing for the silent-drop lesson, and written alongside the client change rather than discovered after. Verified against real data before deploying, by splicing production rows into the shipped page as fixtures and driving it in a browser: seven reports render with the right reporter and box, the day filter and the test toggle both work, prefs persist, and the create form's guard fires. That caught a JS syntax error (a Czech quote closed with an ASCII one inside a double-quoted string, which would have taken the whole dashboard down — PocketBase serves this file with no build step) and a missing space that rendered "ďáblashlédnuto".
2026-08-08 21:29:51 +00:00
// A minute is plenty; this is not a metrics system. The admin view is excluded
// because a refresh under a half-typed form would wipe it.
timer = setInterval(function(){ if (prefs.view !== "admin") load(); }, 60000);
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
}
if (tok) start(); else el("login").hidden = false;
</script>
</body>
</html>