A blocked case was showing the last claim it managed to check -- 'and the app
knows it is a child profile' -- rather than the reason it stopped, which is the
one thing the card exists to tell the owner. The message now follows the
outcome: a blocked case is asking him for something, so it shows the request; a
failed one is asking about the app, so it shows the claim that did not hold.
Screenshots also needed a file token. e2e_runs is superuser-only, which makes its
files protected, and an <img> tag sends no Authorization header -- so without one
every picture on the page is a broken icon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The evidence never appeared on a card. PocketBase rewrites an uploaded filename:
it splits at the first dot and inserts a random token, then flattens everything
that is not a letter or a digit and collapses the runs. So signin.happy__home.png
arrived first as signin_lbrsqmmxo0.happy__home.png -- mangled past recognition,
with signin.wrong-password losing six characters of its middle -- and after the
dots came out, as signin_happy_home_czik8h4h7b.png.
Both sides are normalised the same way now rather than trusting a name to
survive a round trip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Testy tab on the status dashboard and an `e2e_runs` collection behind it. The
owner's call to put it here rather than stand up a service: a whole application
to display a table of results would cost more to keep alive than the thing it
displays, and the VPS's disk is already the scarce resource.
The screenshots ride along and live exactly as long as the run does. A result
whose evidence has been deleted is a claim again rather than a record, and
deleting a run takes its pictures with it, which is the retention policy in one
sentence.
Five outcomes on the cards, not two, because the distinction is the whole point
of the gate: "nelze zde" is a target that cannot ask the question, "čeká na
tebe" is something only the owner can unblock, and neither may be allowed to
read as a pass. The verdict line answers the only question a release asks --
whether anything that blocks shipping has failed -- and the counts underneath
say how much of the catalogue is still unwritten, so a green card cannot quietly
mean an empty one.
Superuser-only, like the rest of this dashboard: it names internal ids and
carries screenshots of a signed-in app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added beside the upstream panel and for the same reason: the owner reported
anime stuttering and it took an evening of measurement to establish that the
television was fine and TorBox's CDN had dipped to 5.5 Mbps. That is a question
a dashboard should answer in a glance.
Median rather than mean, because one fast session should not hide ten slow ones,
and the share of watched time spent waiting alongside it, because that is the
number that matches what the viewer actually felt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added after a day spent establishing by hand that AniList had been refusing
every query for days. Nothing said so: the app fell back, the fallback was
quietly wrong about episodes, and the first anyone knew was a family member
reporting a rail of week-old episodes.
What makes the section useful rather than decorative is the two columns beside
the light. "anilist: down" only says to go and look; "anilist: nedostupné,
anime katalog a kalendář, beze změny od pátku" is the whole answer, including
the sentence AniList itself puts in the body of its 403.
Uptime over a day and a week, from raw samples taken every five minutes, so the
question "was it flaky or has it been out since Friday" has an answer on the
screen rather than in a log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Forty five of them across the account page and the status dashboard, in prose
the owner and his family read. They are a tell: the writing reads as machine
written, which is the opposite of what a family app should feel like.
Most become a full stop or a comma. Headings that were a name plus an explainer
take a colon instead. Two subtitles that were dash-led asides become
parentheses, and the language selects lose the construction entirely: "Zvuk,
hlavní" was never how anyone says it, so they are now "Hlavní zvuk" and
"Záložní zvuk", "Hlavní titulky" and "Záložní titulky", "Zvuk u anime" and
"Titulky u anime".
Six are deliberately left: the em dash that stands in for an empty value in a
table cell or an unset field. That is a typographic convention rather than
prose, and replacing it with a word puts noise in every empty cell. Code
comments are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Publishing a notice did nothing and said it had worked. `api()` took only
`path` and dropped the options object every caller passes, so
`api(path, {method: "POST", body: ...})` fetched the URL. The read
succeeded -- notices are publicly listable -- so the page printed
"zveřejněno" over a collection that had not changed. Switching a notice
on or off is a PATCH and went the same way.
It takes method and body now, and reports what PocketBase said rather
than a bare status, so a permissions problem cannot look like an outage.
Found because the owner tried to tell the family that the anime tab is
down -- AniList have disabled their API, and every call returns 403 with
"The AniList API has been temporarily disabled due to severe stability
issues" -- and the notice never reached anyone.
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.
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>
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>
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>
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.
Caught in a screenshot, invisible in the source: sharing a grid row with
the create form left the five-column table about half the page, so every
date broke as "2026-" / "08-05", the action button wrapped to two lines,
and a long address split mid-word. A wrapped date reads as two dates.
Full width, the address takes the slack, and the four columns that must
never wrap say so. Verified by counting line boxes per cell rather than
by eye — my first check compared cell HEIGHTS and flagged all five rows,
which was just the button's own height.
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".
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.
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.