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">
|
2026-09-05 17:53:30 +00:00
|
|
|
|
<title>Amber: stav služeb</title>
|
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
|
|
|
|
<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}
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
.accts input.pw{width:190px;margin-right:6px}
|
|
|
|
|
|
.accts td:last-child{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
|
|
|
|
.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}
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
.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}
|
2026-08-08 21:33:22 +00:00
|
|
|
|
/* 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>
|
2026-09-05 17:53:30 +00:00
|
|
|
|
<h2>Amber: stav služeb</h2>
|
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
|
|
|
|
<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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 19:50:21 +00:00
|
|
|
|
// Takes the options its callers have always passed.
|
|
|
|
|
|
//
|
|
|
|
|
|
// It used to accept only `path`, so every write on this page -- publishing a
|
|
|
|
|
|
// notice, switching one off -- was quietly performed as a GET. The read
|
|
|
|
|
|
// succeeded (the collection is publicly listable), so the page reported
|
|
|
|
|
|
// "zveřejněno" and nothing had been written. A notice about the anime tab being
|
|
|
|
|
|
// down never reached anyone.
|
|
|
|
|
|
async function api(path, opts){
|
|
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
var init = { method: opts.method || "GET", headers: { Authorization: tok } };
|
|
|
|
|
|
if (opts.body !== undefined) {
|
|
|
|
|
|
init.headers["Content-Type"] = "application/json";
|
|
|
|
|
|
init.body = JSON.stringify(opts.body);
|
|
|
|
|
|
}
|
|
|
|
|
|
var r = await fetch(path, init);
|
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 (r.status === 401) { logout(); throw new Error("401"); }
|
2026-09-02 19:50:21 +00:00
|
|
|
|
if (!r.ok) {
|
|
|
|
|
|
// PocketBase explains itself in the body; say what it said rather than a
|
|
|
|
|
|
// bare status, or a permission problem looks the same as an outage.
|
|
|
|
|
|
var detail = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
var j = await r.json();
|
|
|
|
|
|
if (j && j.message) detail = ": " + j.message;
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
throw new Error("HTTP " + r.status + detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.status === 204) return 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
|
|
|
|
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 dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
/* Every group, for the picker and for the create form's dropdown. Failure is an
|
|
|
|
|
|
empty list rather than a throw: a dashboard that renders nothing because one
|
|
|
|
|
|
collection is missing is worse than one that says there are no groups yet. */
|
|
|
|
|
|
async function allTemplates(){
|
|
|
|
|
|
try {
|
|
|
|
|
|
var r = await records("onboarding_template", { perPage: 50, sort: "name" });
|
|
|
|
|
|
return r.items || [];
|
|
|
|
|
|
} catch (_) { return []; }
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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){
|
Stop choosing an update variant, and drop the 18+ controls
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
2026-08-30 02:09:10 +00:00
|
|
|
|
return "<tr><td>" + esc(r.platform) +
|
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
|
|
|
|
"</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("");
|
2026-09-05 17:53:30 +00:00
|
|
|
|
return '<section class="wide"><h2>Vydání: co se rozdává</h2><table>' +
|
Stop choosing an update variant, and drop the 18+ controls
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
2026-08-30 02:09:10 +00:00
|
|
|
|
"<tr><th>platforma</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>" +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
'<p class="note">Buildy se nemusí shodovat mezi platformami. Když se vydává jen ' +
|
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
|
|
|
|
"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("");
|
2026-09-05 17:53:30 +00:00
|
|
|
|
return '<section><h2>Klienti za 24 h</h2><div class="kv">' +
|
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><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, ' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
"které počítadla nevidí, proto je zvlášť. Podrobnosti jsou v <b>Hlášení</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
|
|
|
|
"</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>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-07 20:05:42 +00:00
|
|
|
|
// Which of the services we depend on are alive, and how much of the time they
|
|
|
|
|
|
// have been.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This section exists because AniList was down for days and nothing said so:
|
|
|
|
|
|
// the app fell back, the fallback was quietly wrong, and the first anyone knew
|
|
|
|
|
|
// was a family member reporting a rail of stale episodes. What makes it useful
|
|
|
|
|
|
// rather than decorative is the two columns beside the light — what breaks when
|
|
|
|
|
|
// this one is out, and since when — because "anilist: down" only says to go and
|
|
|
|
|
|
// look, while "anilist: down since pátek, anime katalog" is the whole answer.
|
|
|
|
|
|
function panelUpstreams(a){
|
|
|
|
|
|
var u = a && a.upstreams;
|
|
|
|
|
|
if (!u || !u.length) return '<section class="wide"><h2>Cizí služby</h2>' +
|
|
|
|
|
|
'<p class="note">zatím žádné měření</p></section>';
|
|
|
|
|
|
var down = 0;
|
|
|
|
|
|
var rows = u.map(function(x){
|
|
|
|
|
|
if (!x.ok) down++;
|
|
|
|
|
|
var pill = x.ok ? '<span class="pill ok">běží</span>'
|
|
|
|
|
|
: '<span class="pill no">nedostupné</span>';
|
|
|
|
|
|
// A percentage is only meaningful once there is something to average.
|
|
|
|
|
|
var day = x.uptime24h === null ? "—" : x.uptime24h + " %";
|
|
|
|
|
|
var week = x.uptime7d === null ? "—" : x.uptime7d + " %";
|
|
|
|
|
|
// The reason, when the service gave one. AniList says why it is off in the
|
|
|
|
|
|
// body of a 403, and that sentence saves the next person an investigation.
|
|
|
|
|
|
var why = x.detail ? '<div class="note">' + esc(String(x.detail).slice(0,110)) + "</div>" : "";
|
|
|
|
|
|
return "<tr><td>" + pill + "</td><td><b>" + esc(x.name) + "</b>" + why +
|
|
|
|
|
|
'</td><td class="note">' + esc(x.matters || "") + "</td>" +
|
|
|
|
|
|
'<td class="note">' + (x.since ? esc(sinceLabel(x.since)) : "—") + "</td>" +
|
|
|
|
|
|
'<td class="num">' + day + '</td><td class="num">' + week +
|
|
|
|
|
|
'</td><td class="num">' + (x.ms || 0) + " ms</td></tr>";
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
return '<section class="wide"><h2>Cizí služby' +
|
|
|
|
|
|
(down ? ' <span class="pill no">' + down + " mimo provoz</span>" : "") + "</h2>" +
|
|
|
|
|
|
"<table><tr><th></th><th>služba</th><th>na čem záleží</th><th>beze změny od</th>" +
|
|
|
|
|
|
'<th class="num">24 h</th><th class="num">7 dní</th><th class="num">odezva</th></tr>' +
|
|
|
|
|
|
rows + "</table>" +
|
|
|
|
|
|
'<p class="note">Měří se každých pět minut. Sloupce 24 h a 7 dní jsou podíl měření, ' +
|
|
|
|
|
|
"kdy služba odpovídala.</p></section>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// "od pátku 14:05" beats an ISO timestamp on a wall-mounted dashboard, and a
|
|
|
|
|
|
// relative age beats both once it is under a day.
|
|
|
|
|
|
function sinceLabel(unix){
|
|
|
|
|
|
var d = new Date(unix * 1000), now = Date.now();
|
|
|
|
|
|
var mins = Math.round((now - d.getTime()) / 60000);
|
|
|
|
|
|
if (mins < 60) return "před " + mins + " min";
|
|
|
|
|
|
if (mins < 60 * 24) return "před " + Math.round(mins / 60) + " h";
|
|
|
|
|
|
var days = ["neděle","pondělí","úterý","středa","čtvrtek","pátek","sobota"];
|
|
|
|
|
|
return days[d.getDay()] + " " + ("0" + d.getHours()).slice(-2) + ":" +
|
|
|
|
|
|
("0" + d.getMinutes()).slice(-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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>" +
|
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
|
|
|
|
}
|
|
|
|
|
|
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +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>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:53:30 +00:00
|
|
|
|
return '<section class="wide"><h2>Chyby v čase za ' + d.window + ' dní</h2>' +
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
'<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>" +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
tbl("Podle vydání: podíl chyb, ne počet", d.byVersion, true) +
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
tbl("Podle systému", d.byPlatform, 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>';
|
|
|
|
|
|
|
2026-09-05 17:53:30 +00:00
|
|
|
|
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á. ' +
|
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
|
|
|
|
"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("");
|
2026-09-05 17:53:30 +00:00
|
|
|
|
return '<section class="wide"><h2>Zařízení: kde se kouká</h2><table>' +
|
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>krabice</th><th>kdo</th><th>build</th><th class='num'>sezení</th>" +
|
|
|
|
|
|
"<th>naposled</th></tr>" + body + "</table>" +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
'<p class="note">Identifikátor za tečkou je náhodný a jen místní, říká ' +
|
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
|
|
|
|
"„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. */
|
|
|
|
|
|
|
Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.
A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.
Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.
`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00
|
|
|
|
|
|
|
|
|
|
/* A message the family sees in the app, for breakage we cannot fix.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Lives here rather than only in the PocketBase admin UI because the moment it
|
|
|
|
|
|
* is needed is the moment something is already going wrong, and a form that
|
|
|
|
|
|
* takes one sentence and a button beats remembering a collection name and a
|
|
|
|
|
|
* rule syntax under pressure.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The warning is not decoration. The collection is PUBLICLY readable so a notice
|
|
|
|
|
|
* still appears when signing in is itself what is broken, which means anything
|
|
|
|
|
|
* typed here is world-readable to whoever finds the backend. */
|
|
|
|
|
|
function viewNotices(ns){
|
|
|
|
|
|
var rows = (ns || []).map(function(n){
|
|
|
|
|
|
var ends = n.endsAt ? String(n.endsAt).substring(0, 16) : "—";
|
|
|
|
|
|
return "<tr><td>" + esc(n.message) + "</td>" +
|
|
|
|
|
|
"<td>" + (n.active ? '<span class="pill wn">viditelné</span>'
|
|
|
|
|
|
: '<span class="pill ok">skryté</span>') + "</td>" +
|
|
|
|
|
|
"<td class='mono'>" + esc(ends) + "</td>" +
|
|
|
|
|
|
'<td><button data-notice="' + esc(n.id) + '" data-to="' +
|
|
|
|
|
|
(n.active ? "0" : "1") + '">' +
|
|
|
|
|
|
(n.active ? "skrýt" : "zobrazit") + "</button></td></tr>";
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
|
|
return '<section class="wide"><h2>Oznámení v aplikaci</h2>' +
|
|
|
|
|
|
'<p class="note">Uvidí ho každý v aplikaci, i nepřihlášený. ' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
'Nepiš sem nic citlivého: jména služeb, adresy ani klíče.</p>' +
|
Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.
A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.
Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.
`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00
|
|
|
|
'<div class="kv"><label>Text<input id="nMsg" maxlength="400" ' +
|
|
|
|
|
|
'placeholder="Anime dnes nefunguje, je to výpadek u poskytovatele."></label>' +
|
|
|
|
|
|
'<label>Skrýt po (nepovinné)<input id="nEnds" type="datetime-local"></label></div>' +
|
|
|
|
|
|
'<button id="nSave">Zveřejnit</button> <span id="nMsgOut" class="note"></span>' +
|
|
|
|
|
|
(rows ? '<table><tr><th>Text</th><th>Stav</th><th>Do</th><th></th></tr>' +
|
|
|
|
|
|
rows + '</table>' : '<p class="note">zatím žádné</p>') +
|
|
|
|
|
|
'</section>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveNotice(){
|
|
|
|
|
|
var msg = (el("nMsg").value || "").trim();
|
|
|
|
|
|
var out = el("nMsgOut");
|
|
|
|
|
|
if (!msg) { out.textContent = "napiš text"; return; }
|
|
|
|
|
|
var body = { message: msg, active: true };
|
|
|
|
|
|
var ends = el("nEnds").value;
|
|
|
|
|
|
if (ends) body.endsAt = new Date(ends).toISOString().replace("T", " ").substring(0, 19) + "Z";
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api("/api/collections/notices/records", { method: "POST", body: body });
|
|
|
|
|
|
out.textContent = "zveřejněno";
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) { out.textContent = "nepovedlo se: " + e.message; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setNoticeActive(id, to){
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api("/api/collections/notices/records/" + id,
|
|
|
|
|
|
{ method: "PATCH", body: { active: to === "1" } });
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) { alert("nepovedlo se: " + e.message); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Set someone's password directly.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Exists because the emailed reset cannot be relied on, and the reason is not
|
|
|
|
|
|
* the mail server: PocketBase looks accounts up **case-sensitively**, so a
|
|
|
|
|
|
* family member typing `Vojta.Markup@seznam.cz` for an account stored in
|
|
|
|
|
|
* lowercase matches nothing — and the endpoint answers 204 either way, so that
|
|
|
|
|
|
* nobody can use it to discover which addresses exist. From the sofa that is
|
|
|
|
|
|
* indistinguishable from mail being broken, which is how it was reported.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The app now lower-cases addresses before sending them, so this should stop
|
|
|
|
|
|
* happening. This stays for the case it cannot fix: somebody who has genuinely
|
|
|
|
|
|
* forgotten a password and cannot receive mail.
|
|
|
|
|
|
*
|
|
|
|
|
|
* No confirmation prompt. The button is one row of a table only a superuser can
|
|
|
|
|
|
* load, the field says what it does, and an accidental press with an empty box
|
|
|
|
|
|
* does nothing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function setPassword(id, btn){
|
|
|
|
|
|
var input = el("pw_" + id);
|
|
|
|
|
|
var out = el("pwout_" + id);
|
|
|
|
|
|
var pw = (input.value || "").trim();
|
|
|
|
|
|
out.style.color = "";
|
|
|
|
|
|
if (pw.length < 8) { out.textContent = "nejméně 8 znaků"; return; }
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
out.textContent = "nastavuji…";
|
|
|
|
|
|
try {
|
|
|
|
|
|
// A superuser may set a password without the old one. `passwordConfirm` is
|
|
|
|
|
|
// required by the collection rules even so.
|
|
|
|
|
|
await api("/api/collections/users/records/" + id,
|
|
|
|
|
|
{ method: "PATCH", body: { password: pw, passwordConfirm: pw } });
|
|
|
|
|
|
// Cleared immediately: it was only ever here to be typed and handed over,
|
|
|
|
|
|
// and a password left sitting in a field is one that ends up in a
|
|
|
|
|
|
// screenshot.
|
|
|
|
|
|
input.value = "";
|
2026-09-05 17:53:30 +00:00
|
|
|
|
out.textContent = "hotovo, předej mu ho";
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
out.style.color = "var(--bad)";
|
|
|
|
|
|
out.textContent = "nepovedlo se: " + e.message;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
function viewAdmin(users, tpls){
|
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
|
|
|
|
var rows = (users || []).map(function(u){
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
var id = esc(u.id);
|
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
|
|
|
|
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
|
|
|
|
|
|
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
|
|
|
|
|
|
"<td><input class='pw' id='pw_" + id + "' type='text' autocomplete='off' " +
|
|
|
|
|
|
"placeholder='nové heslo, nejméně 8 znaků'>" +
|
|
|
|
|
|
"<button data-pw='" + id + "'>Nastavit</button>" +
|
|
|
|
|
|
"<span class='note' id='pwout_" + id + "'></span></td></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
|
|
|
|
}).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>' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
'<div class="span"><div class="lbl">sdílené nastavení</div>' +
|
|
|
|
|
|
'<select id="nGroup" style="width:100%">' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
'<option value="">žádné, zdroje si zadá sám</option>' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
(tpls || []).map(function(t){
|
|
|
|
|
|
return '<option value="' + esc(t.id) + '"' + (t.isDefault ? " selected" : "") +
|
|
|
|
|
|
">" + esc(t.name || "(bez názvu)") + "</option>"; }).join("") +
|
|
|
|
|
|
"</select>" +
|
|
|
|
|
|
'<p class="note">Zdroje i TMDB klíč nastavím hned a nemusí zadávat kód. ' +
|
|
|
|
|
|
"Kdo si nese vlastní účty, dostane „žádné“.</p></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 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>' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
'<p class="note">Heslo je vidět schválně, vytváříš ho pro někoho jiného a ' +
|
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
|
|
|
|
"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>" +
|
|
|
|
|
|
|
2026-08-08 21:33:22 +00:00
|
|
|
|
'<section class="wide"><h2>Účty</h2><table class="accts">' +
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
"<tr><th>e-mail</th><th>strop</th><th>vznik</th><th>heslo</th></tr>" +
|
|
|
|
|
|
(rows || "<tr><td colspan=4 class='note'>nic</td></tr>") + "</table>" +
|
Say why the direct password field is there, accurately
The note justified it with "reset silently fails when somebody types a capital
letter". That stopped being true: both the login form and the reset form on the
site lowercase the address before sending, the app does the same in
normalizeEmail, and all nine stored addresses are lowercase already. A dashboard
that states a fixed bug as current makes the owner distrust a working feature.
What is still true, and is the actually useful thing to know while standing at
this table, is that the endpoint answers "sent" whether or not the address
matched, on purpose, so a failed reset is undiagnosable from the outside.
The second note is today's lesson, which cost an hour: a reset the owner sends
to his own address is delivered by Proton as his own outgoing mail, so it sits
in All Mail and never appears in the inbox. He is the only person this affects,
because he is the only recipient who is also the sender.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:56:14 +00:00
|
|
|
|
'<p class="note">Heslo nastavíš rovnou, bez e-mailu. Hodí se to, když reset ' +
|
|
|
|
|
|
"nedorazí, protože z odpovědi serveru to stejně nepoznáš: PocketBase odpoví " +
|
|
|
|
|
|
"„odesláno“ i na adresu, ke které žádný účet není, aby přes něj nešlo " +
|
|
|
|
|
|
"zjišťovat, kdo je zaregistrovaný.</p>" +
|
|
|
|
|
|
'<p class="note">Když si reset posíláš na svoji vlastní adresu, hledej ho ve ' +
|
|
|
|
|
|
"Všechna pošta. Odesílatel sedí na stejném Proton účtu, takže se uloží mezi " +
|
|
|
|
|
|
"odeslané a do Doručené se nedostane.</p>" +
|
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
|
|
|
|
'<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
"aktualizaci. Server ji vybírá podle tohohle příznaku, klient si ji nemůže " +
|
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
|
|
|
|
"vyžádat.</p></section>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
/* ── Sdílená nastavení (onboarding_template) ──────────────────────────────────
|
|
|
|
|
|
The shared config a new member's first setup pulls down, unlocked by the code of
|
|
|
|
|
|
their group. One row per group the owner shares accounts with, so the family and
|
|
|
|
|
|
friends never land on each other's subscriptions; superuser-only on every rule,
|
|
|
|
|
|
so the dashboard reads and writes them directly.
|
|
|
|
|
|
|
|
|
|
|
|
The picker below is the only place these are edited. It used to edit "the"
|
|
|
|
|
|
template, chosen by recency, which is the bug that made a second group unsafe --
|
|
|
|
|
|
see pb_migrations/1791000000_onboarding_template_codes.js.
|
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
|
|
|
|
|
|
|
|
|
|
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. */
|
|
|
|
|
|
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
/* Which group the editor is pointed at: a record id, or "new". Held here rather
|
|
|
|
|
|
than read off the <select> because `load()` re-renders the section from scratch
|
|
|
|
|
|
and the choice has to survive that. */
|
|
|
|
|
|
var tplSel = null;
|
|
|
|
|
|
|
|
|
|
|
|
function viewTemplate(list){
|
|
|
|
|
|
var rows = list || [];
|
|
|
|
|
|
var sel = tplSel ? rows.filter(function(r){ return r.id === tplSel; })[0] : null;
|
|
|
|
|
|
if (!sel && tplSel !== "new") {
|
|
|
|
|
|
// Default to the family row, so opening the page lands where it always did.
|
|
|
|
|
|
sel = rows.filter(function(r){ return r.isDefault; })[0] || rows[0] || null;
|
|
|
|
|
|
tplSel = sel ? sel.id : "new";
|
|
|
|
|
|
}
|
|
|
|
|
|
var v = sel || {};
|
|
|
|
|
|
var opts = rows.map(function(r){
|
|
|
|
|
|
return '<option value="' + esc(r.id) + '"' +
|
|
|
|
|
|
(sel && r.id === sel.id ? " selected" : "") + ">" +
|
|
|
|
|
|
esc(r.name || "(bez názvu)") + (r.isDefault ? " (výchozí)" : "") + "</option>";
|
|
|
|
|
|
}).join("") + '<option value="new"' + (tplSel === "new" ? " selected" : "") +
|
|
|
|
|
|
">+ nová skupina</option>";
|
2026-09-05 17:53:30 +00:00
|
|
|
|
return '<section class="wide"><h2>Sdílené nastavení: co dostane nový člen</h2>' +
|
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="form">' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
'<div class="span"><div class="lbl">skupina</div>' +
|
|
|
|
|
|
'<select id="tGroup" style="width:100%">' + opts + "</select></div>" +
|
|
|
|
|
|
'<div><div class="lbl">název skupiny</div><input id="tName" ' +
|
|
|
|
|
|
'autocomplete="off" placeholder="rodina, přátelé…" value="' +
|
|
|
|
|
|
esc(v.name || "") + '"></div>' +
|
|
|
|
|
|
'<div><div class="lbl">kód téhle skupiny</div><input id="tCode" ' +
|
|
|
|
|
|
'type="password" autocomplete="off" value="' + esc(v.code || "") + '"></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"><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>' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
'<label class="row span" style="color:var(--muted);font-size:13px">' +
|
|
|
|
|
|
'<input type="checkbox" id="tDefault"' + (v.isDefault ? " checked" : "") +
|
|
|
|
|
|
' style="width:auto;accent-color:var(--accent)"> ' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
"výchozí skupina, tuhle otevře starý kód z AMBER_INVITE_CODE</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="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 ' +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
"klíč k TorBoxu, proto jsou schované a proto je tahle stránka jen pro tebe. " +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
"Kdo dostane kód téhle skupiny, stáhne si přesně tyhle zdroje.</p>" +
|
|
|
|
|
|
'<p class="note">Každá skupina má vlastní údaje a vlastní kód, takže přátelé ' +
|
|
|
|
|
|
"nesdílí účty s rodinou. Kód musí být unikátní; dvě skupiny se stejným kódem " +
|
|
|
|
|
|
"databáze odmítne.</p>" +
|
2026-09-05 17:53:30 +00:00
|
|
|
|
(rows.length ? "" : '<div class="warnbox">Žádná skupina ještě není uložená, ' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
"první uložení ji vytvoří.</div>") + "</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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveTemplate(){
|
|
|
|
|
|
var out = el("tOut");
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
var name = el("tName").value.trim();
|
|
|
|
|
|
if (!name) {
|
2026-09-05 17:53:30 +00:00
|
|
|
|
out.innerHTML = '<div class="warnbox">Skupina musí mít název, jinak ji ' +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
"v seznamu nerozeznáš od ostatních.</div>";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
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
|
|
|
|
out.innerHTML = '<p class="note">ukládám…</p>';
|
|
|
|
|
|
var body = {
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
name: name,
|
|
|
|
|
|
code: el("tCode").value.trim(),
|
|
|
|
|
|
isDefault: el("tDefault").checked,
|
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
|
|
|
|
addonUrl: el("tAddon").value.trim(),
|
|
|
|
|
|
czechAddonUrl: el("tCzech").value.trim(),
|
|
|
|
|
|
tmdbKey: el("tTmdb").value.trim(),
|
|
|
|
|
|
note: el("tNote").value.trim(),
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
var creating = (!tplSel || tplSel === "new");
|
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" +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
(creating ? "" : "/" + tplSel), {
|
|
|
|
|
|
method: creating ? "POST" : "PATCH",
|
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
|
|
|
|
headers: { "Content-Type": "application/json", Authorization: tok },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!r.ok) {
|
|
|
|
|
|
var d = await r.json();
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
// Per-field, because the one that actually happens here is a duplicate code
|
|
|
|
|
|
// hitting the unique index, and a bare "400" would not say so.
|
|
|
|
|
|
var why = d && d.data && Object.keys(d.data).length
|
|
|
|
|
|
? 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
|
|
|
|
}
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
var saved = await r.json();
|
|
|
|
|
|
tplSel = saved.id;
|
|
|
|
|
|
|
|
|
|
|
|
// **Exactly one default.** The route's fallback for the old AMBER_INVITE_CODE
|
|
|
|
|
|
// takes "the" isDefault row, so two of them would
|
|
|
|
|
|
// put us straight back to the bug this whole change fixed: which credentials the
|
|
|
|
|
|
// old code hands out would depend on edit order. Ticking the box therefore
|
|
|
|
|
|
// unticks it everywhere else rather than being quietly ignored.
|
|
|
|
|
|
var moved = "";
|
|
|
|
|
|
if (body.isDefault) {
|
|
|
|
|
|
var all = await allTemplates();
|
|
|
|
|
|
for (var i = 0; i < all.length; i++) {
|
|
|
|
|
|
if (all[i].id !== saved.id && all[i].isDefault) {
|
|
|
|
|
|
await fetch("/api/collections/onboarding_template/records/" + all[i].id, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Authorization: tok },
|
|
|
|
|
|
body: JSON.stringify({ isDefault: false }),
|
|
|
|
|
|
});
|
|
|
|
|
|
moved = " Výchozí skupina je teď „" + esc(name) + "“, ne „" +
|
|
|
|
|
|
esc(all[i].name || "(bez názvu)") + "“.";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// `load()` replaces the whole section, so writing the message first and calling
|
|
|
|
|
|
// it after meant the confirmation was destroyed a moment later -- it has always
|
|
|
|
|
|
// flashed and vanished here. That is worse now than it was: `moved` reports that
|
|
|
|
|
|
// the default group changed, which is exactly the sentence somebody needs to
|
|
|
|
|
|
// read. So re-render first, then put the message into the fresh element.
|
|
|
|
|
|
var msg = '<div class="okbox">Uloženo. Nový člen to dostane při prvním ' +
|
|
|
|
|
|
"nastavení; už nastavené účty se tím nemění." + moved + "</div>";
|
|
|
|
|
|
await load();
|
|
|
|
|
|
if (el("tOut")) el("tOut").innerHTML = msg;
|
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 (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. */
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
async function preloadSharedConfig(userId, name, password, tplId){
|
|
|
|
|
|
// By id, because the dashboard now asks WHICH group. Reading "the" template here
|
|
|
|
|
|
// is what made this preload family-only.
|
|
|
|
|
|
var tpl = null;
|
|
|
|
|
|
try { tpl = await api("/api/collections/onboarding_template/records/" + tplId); }
|
|
|
|
|
|
catch (_) { tpl = null; }
|
|
|
|
|
|
if (!tpl) throw new Error("sdílené nastavení téhle skupiny se nepodařilo načíst");
|
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 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,
|
|
|
|
|
|
// 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 = "";
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
var gid = el("nGroup") ? el("nGroup").value : "";
|
|
|
|
|
|
if (gid) {
|
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
|
|
|
|
// 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 {
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
await preloadSharedConfig(d.id, name, pass, gid);
|
2026-09-05 17:53:30 +00:00
|
|
|
|
extra = " Sdílené zdroje jsou nastavené, po přihlášení je uvidí sám.";
|
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 (e2) {
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
extra = ' <b style="color:var(--warn)">Účet je založený, ale sdílené ' +
|
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
|
|
|
|
"nastavení se nepodařilo přiřadit (" + esc(e2.message) +
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
"). Zdroje si zadá sám kódem své skupiny.</b>";
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
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) +
|
Stop choosing an update variant, and drop the 18+ controls
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
2026-08-30 02:09:10 +00:00
|
|
|
|
"</code> je založený." + 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 = "";
|
Stop choosing an update variant, and drop the 18+ controls
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
2026-08-30 02:09:10 +00:00
|
|
|
|
el("nRating").value = "";
|
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>";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function wire(){
|
|
|
|
|
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
|
|
|
|
|
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
|
|
|
|
|
});
|
|
|
|
|
|
if (el("createBtn")) el("createBtn").onclick = createAccount;
|
Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 16:34:57 +00:00
|
|
|
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-pw]"), function(b){
|
|
|
|
|
|
b.onclick = function(){ setPassword(b.getAttribute("data-pw"), b); };
|
|
|
|
|
|
});
|
Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.
A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.
Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.
`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00
|
|
|
|
if (el("nSave")) el("nSave").onclick = saveNotice;
|
|
|
|
|
|
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){
|
|
|
|
|
|
b.onclick = function(){ setNoticeActive(b.getAttribute("data-notice"), b.getAttribute("data-to")); };
|
|
|
|
|
|
});
|
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;
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
if (el("tGroup")) el("tGroup").onchange = function(){
|
|
|
|
|
|
// Re-render from the selection. Unsaved edits in the fields are dropped, which
|
|
|
|
|
|
// is the honest behaviour: they belonged to the group you just left.
|
|
|
|
|
|
tplSel = el("tGroup").value; load();
|
|
|
|
|
|
};
|
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("tShow")) el("tShow").onchange = function(){
|
|
|
|
|
|
var ty = el("tShow").checked ? "text" : "password";
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
// The code is a shared secret too, so it hides and reveals with the rest.
|
|
|
|
|
|
["tAddon", "tCzech", "tTmdb", "tCode"].forEach(function(id){ el(id).type = ty; });
|
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
|
|
|
|
};
|
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 });
|
The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:54:12 +00:00
|
|
|
|
var tps = await allTemplates();
|
Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.
A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.
Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.
`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00
|
|
|
|
var ns = await records("notices", { sort: "-created", perPage: 20 })
|
|
|
|
|
|
.catch(function(){ return { items: [] }; });
|
|
|
|
|
|
el("main").innerHTML = viewNotices(ns.items || []) +
|
|
|
|
|
|
viewAdmin(us.items || [], tps) + viewTemplate(tps);
|
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");
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
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) +
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
panelClients(d.clients) + panelApi(d.amberApi) + panelAccounts(d.accounts) +
|
2026-09-07 20:05:42 +00:00
|
|
|
|
panelUpstreams(d.amberApi) +
|
Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
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. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 19:34:42 +00:00
|
|
|
|
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>
|