v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s

An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
2026-10-15 18:37:50 +02:00
parent 85f65c70d8
commit e66d4669d6
120 changed files with 16239 additions and 9866 deletions
+191 -13
View File
@@ -1,15 +1,193 @@
# REPORT — v0.314.0: the gate on restore, test builds and the floor, Prowlarr's link, Plex by address (2026-10-15)
# REPORT — v0.315.0: one app page with four tabs (2026-10-15)
Baseline `e209a8d` (0.313.0). Design: felhom.eu `architecture/09-update-architecture.md` §3 decisions 46–49, 250–253;
`07-backup-architecture.md`; `15-media-library.md` §6. Evidence: felhom.eu `documentation/audits/gate-restore-2026-10-15/`.
## 1. Baselines
- **R-1057:** the per-app backup records the setup gate; a restore puts it back as it was. Ten mutations red-proved.
Live on 9202: a stranger met the gate after the restore of a pre-setup backup (before: 200).
- **R-1053:** `X.Y.Z-rc.N` is below `X.Y.Z` for the floor; the button never offers it; the skip is a WARN. Live: Tester 1
on rc.1 was moved to 0.314.0 by the floor by itself. Six older floor tests now wait for the background update.
- **R-1058:** Prowlarr's own start runs the wiring. Live on 9202: linked ~40 s after Prowlarr ran, no button.
- **R-1060:** every Plex call by address. Live on 9201 with a fake code: Plex took it as local (500 from plex.tv, before
401). The real claim waits on the operator's code.
- **Release:** MinAgent 0.155.0. Three test boxes via per-customer floors (token), box walk green, global floor 0.314.0 at
13:11 UTC by CC's token, read back from the hub DB with its WAL; per-customer floors cleared. No golden (decision 250).
- Gates: `controller_gates.py` OK; `go test ./...` green.
| Repo | `main` at start | Version | → |
|---|---|---|---|
| felhom-controller | `85f65c7` | v0.314.0 | **v0.315.0** |
| felhom.eu | `06e7cf8c` (the task said `3263bb2`; it had moved — two other sessions are pushing) | docs only | — |
| app-catalog-felhom.eu | `e67aad4` | read only | — |
Architecture read before any claim: `02-controller-module-map.md` (`web/`, the R-571 alert-placement
rule), `10-localisation.md` §7, `15-media-library.md` (the Plex claim path), and the controller's
`CLAUDE.md`, `REUSE.md` and `.claude/rules/{ui-hungarian,gates,unprompted-work}.md`.
**Where the work ran.** Edited on the Windows workstation; **built and tested on DooPlex** in an
isolated scratch clone (`/mnt/5_hdd/felhom.eu/scratch/cc-apptabs`) so the shared DooPlex checkout —
which two other sessions are using tonight — was never dirtied. The sync normalises line endings to
LF and restores any file it did not mean to touch, because this checkout has `core.autocrlf=true`
while six files (`.gitignore`, `Dockerfile`, `Makefile`, the three infra templates) are stored in git
WITH CRLF; a blanket normalisation rewrote all six on the first attempt.
## 2. Files created / modified
**Created (11)**
`controller/internal/web/app_page.go` · `after_start.go` · `app_page_test.go` · `guide_render_test.go` ·
`controller/internal/stacks/guide_test.go` ·
`templates/app_shell.html` · `app_settings.html` · `app_data.html` · `app_guide.html` · `app_logs.html` · `app_fields.html`
**Deleted (4 templates)** `templates/app_info.html` · `logs.html` · `app_export.html` · `tier2_config.html`
— with the three page handlers that rendered them (`logsHandler`, `exportPageHandler`,
`tier2ConfigPageHandler`). The POST handlers beside them (`tier2ConfigSaveHandler`,
`appEmailToggleHandler`) stay, with their URLs.
**Modified (main ones)** `web/server.go` (routes + the GET-only redirects) · `web/handlers.go`
(`appDetailHandler` → `appCommonData`; `deployHandler` → `deployPageData` + a thin renderer) ·
`web/media_events.go` (`applyPlexClaim` extracted) · `web/tier2_config_handler.go` (redirect targets) ·
`templates/deploy.html` (install-only; 1197 → 544 lines) · `stacks.html`, `dashboard.html`,
`backups_apps.html`, `app_import.html` (links) · `stacks/metadata.go`, `metadata_i18n.go`,
`appinfo_address.go`, `manager.go` (the guide) · both locale files · `scripts/template_id_gate.py`,
`scripts/test_gate_decoys.py`, `scripts/i18n_go_keys.json`.
## 3. Commits on `main`
| Commit | What |
|---|---|
| (see `git log`) | v0.315.0 — the whole slice in one commit, because the template split, the routes and the fixtures cannot be green apart |
## 4. Tests and red-proofs
**New test groups (9 functions across 3 files), all through the production router where routing is
the subject.**
| Test | Asserts |
|---|---|
| `TestAppPage_DeployedDefaultsToSettings` | `/apps/{slug}` renders the settings tab (`data-tab="settings"` + `aria-selected="true"`), all four tabs present, Mukodes offers restart+stop and not start, **the header contains no `stackAction(`**, no Pi/x86 text |
| `TestAppPage_NotDeployedShowsGuideOnly` | no tab bar, `data-install-cta`, every tab suffix → 302 `/apps/grocy`; unknown app 404; unknown suffix 404 |
| `TestAppPage_OldURLsRedirectGETOnly` | the five old GETs → 302 with the right Location, query carried, `?raw=1` still text/plain, unknown name 404, **POST to `/backup` and `/app-email` reaches its handler** |
| `TestAfterStart_PlexThroughRouter` | POST `/apps/plex/after-start/PLEX_CLAIM` → the real registry → a fake Plex, by ADDRESS; the gate moves; the card disappears |
| `TestAfterStart_NoActionNoCard` | an unregistered field: no card, POST 404, **zero calls to the fake** |
| `TestAfterStart_RegistryAloneIsNotAnInvitation` | a REGISTERED action whose field the catalog does not declare: 404, zero calls, the gate untouched |
| `TestAfterStart_CardHiddenWhenNotNeeded` | registered + `Needed()==false` → no card |
| `TestAfterStart_PlexClaimOldRouteStillWorks` | `/apps/plex/plex-claim` runs the same entry |
| `TestInstallPage_BackGoesToApp` | Vissza and Mégsem href `= /apps/grocy`; the post-install links point at the tabs; unknown app 404 |
| `TestAppPage_EachTabRendersItsOwnContent` | one marker per tab, and **the log poller on the log tab only** |
| `TestGuide_RenderRules` | checked renders / empty does not / absent says so / `<script>` escaped / DOMAIN filled |
| `TestGuide_EnglishFallsBackFieldByField` | English FAQ + Hungarian sections; a blank English intro falls back; `checked_version` survives; the Hungarian page is unchanged |
| `TestGuide_*` (stacks, 8 funcs) | parse, the render gate, the overlay field by field, extras dropped+counted, `WithAddress` over every string, `Clone` not shared, and the catalog snapshot carries no guide yet |
**Red-proofs — each break seen failing, then reverted.**
| # | Break | Result |
|---|---|---|
| A | default tab → guide for a deployed app | `TestAppPage_DeployedDefaultsToSettings` FAILS |
| B | drop the not-deployed tab redirect | `TestAppPage_NotDeployedShowsGuideOnly` FAILS |
| C | drop `&& r.Method == http.MethodGet` from the `/backup` redirect | `TestAppPage_OldURLsRedirectGETOnly` FAILS |
| C2 | 302 → 301 | `TestAppPage_OldURLsRedirectGETOnly` FAILS |
| D | remove the `/after-start/` route from `server.go` | `TestAfterStart_PlexThroughRouter` FAILS (404) |
| E1 | drop the no-registered-action guard in `afterStartFields` | `TestAfterStart_NoActionNoCard` FAILS |
| **E2** | drop `!declared` in `appAfterStartHandler` | **PASSED at first — see below** |
| E2′ | the same break, after writing `TestAfterStart_RegistryAloneIsNotAnInvitation` | FAILS: *"POST for a field the catalog does not declare = 200"*, **2 calls reached Plex**, the gate moved |
| F | guide gate `Rendered` → `Sections` | `TestGuide_RenderRules` FAILS |
| G | install Vissza → `/stacks` | `TestInstallPage_BackGoesToApp` FAILS |
| H | `deepCopyStack` stops cloning the guide | `TestDeepCopyStackMetaSharesNoReference` FAILS, naming `Guide.Sections` and `Guide.FAQ` |
| 1–3 | `Rendered()` without `checked_version`; `overlayGuide` replacing the FAQ whole; `WithAddress` skipping the guide | the three matching `TestGuide_*` FAIL |
**E2 is the finding of this session.** The handler has two guards that look like one: the field must
be registered in Go AND declared by this box's catalog. Deleting the second left the whole suite
green, because every test that reached it was already refused by the first. With it deleted, a POST
naming a field the catalog does not declare **reached Plex and opened the setup gate**. The missing
test was written, the break repeated, and it now convicts. This is the project's own
„a comment asserting an invariant needs a test pinning it" class, found the way the rule says to find
it.
**Also caught by a red-proof discipline, not by the suite:** my first full-gate run read
`go test ./... | grep -v ok | head -25`, whose exit code is `head`'s. It reported 0 while
`TestDeepCopyStackMetaSharesNoReference` was failing (break H, caused by the new guide slices). Re-run
capturing `go test`'s own `$?`: **`GO_TEST_RC=0`, 37 packages `ok`, zero `FAIL` lines.** The
„exit codes that lie" trap, hit and corrected in-session.
## 5. Test count
`internal/web` + `internal/stacks` test functions: **1 063 → 1 084** (+21: 12 new functions, 9 of them
sub-tested). Parity CASES: 145 → 148.
## 6. Deployed versions
**Nothing is deployed yet.** The image is built and pushed in §9 below; the floor is NOT raised — per
`.claude/rules/unprompted-work.md` item 6 that waits until it has run on Tester 1, demo-hp and
demo-felhom through the normal update path with a green box walk.
## 7. NOT yet live-validated
1. **The operator's own look at the page in a browser** — one installed and one not-installed app on
demo-hp. Asked for in `STATUS.md`. Claude in Chrome runs on the Windows workstation and was used
for exactly this (§9); the operator's judgement of the LAYOUT is still theirs to give.
2. **A real Plex claim through the new card.** The old path is proven live (R-1060, 2026-10-15); the
new card reaches the same `applyPlexClaim` and is proven against a fake Plex that answers as the
measured one does. No real claim was made — it needs the household's code and a throwaway server.
3. **A catalog guide rendered from a real template.** No template carries `app_info.guide` yet; the
render is proven against fixtures and a unit test.
## 8. Evidence
`/mnt/5_hdd/felhom.eu/evidence/app-page-2026-10-15/` on DooPlex, copied at the end of each phase:
`gates.txt` (the runner, all green), `decoys.txt` (53 decoys), `fullgate.txt` (`go test ./...` with
its own exit code), `redproofs.txt`, and `live/` (§9).
## 9. Live validation
Endpoint-level on demo-hp through the endpoints the UI invokes, **plus a real browser** — Claude in
Chrome runs on this workstation, which is why CC runs here. Both are recorded in `live/`.
## 10. Parity fixtures (the §9 exception), by name
**Removed (33).** `app_info_*` ×21 · `app_export` · `logs` · `deploy_deployed_{running,stopped,restored_login}` ·
`tier2_{not_hdd,not_hdd_protected,not_hdd_same_disk,not_hdd_absent,notarget_disabled,target_ssd,target_auto}`.
**Added (36).** `app_settings_*` ×21 (`not_checked`, `deployed`, `operator_creds`, `updating`,
`setup_gate_{button,probe,native}`, `after_start`, `install_hold`, `close_signup`,
`close_signup_native`, `signup_native`, `signup_native_open_failed`, `signup_closed`, `signup_window`,
`lost_files`, `known_login_changed`, `ladder_steps`, `hold_log`, `running`, `stopped`,
`restored_login`) · `app_data_*` ×9 (`export`, `deployed`, and the seven Tier-2 scenarios) ·
`app_guide_*` ×4 (`installable`, `available`, `full`, `no_guide`) · `app_logs`.
**Every removed case's scenario is carried by a new one** — the names map one to one except that
`app_info_deployed` split (its data-folder half is `app_data_deployed`) and two cases were added for
content no old case had: `app_guide_full` (a rendered guide with sections and FAQ) and
`app_guide_no_guide` (the „no guide yet" line).
**Changed, and NOT covered by the task's exception list (14).** The task's §9(b) names the pages it
expected to change. These fourteen also changed, and each is required by the task's own §2.6 and
Part 3 — a link change and a badge removal ARE byte changes:
- `deploy_new`, `deploy_new_blocked`, `deploy_new_memory_blocked` — the Pi badge removed (Part 3), the
Vissza/Mégsem retargeted (§2.5). *(In §9(b)'s `deploy_*` set.)*
- `stacks_full`, `stacks_unhealthy_stop`, `stacks_install_interrupted` — the Pi badge removed, the
install and log links retargeted. *(In §9(b)'s `stacks_*` set.)*
- `dashboard_full`, `backups_apps_{ok,error,running,other,stale_copy,offsite_offer,interrupted_run}` —
**not in §9(b)'s list**, but §2.6 requires exactly these links to be updated. Stated here rather
than quietly regenerated.
**Byte-identical, and that is the proof of no collateral change:** every other fixture, 98 of them,
including `catchall_app` and `app_import_{empty,bundles}`. The app-import page needed a comment about
why one link deliberately keeps the old URL; a `//` JS comment changed its bytes, so it is written as
`{{- /* … */}}` with the trim marker and the page is unchanged.
## 11. Observations
- **NOT-A-FINDING:** `page.title.tier2_config` became dead the moment `tier2ConfigPageHandler` was
deleted (it was the only producer). Removed from both locale files, from
`scripts/i18n_go_keys.json` and from `i18n_wiring_test.go`'s two lists in the same commit. Caused by
this change, so cleaning it is part of it, not scope creep.
- **NOT-A-FINDING (fixed without a row, the size rule):** `scripts/template_id_gate.py` was
file-scoped, so the shared `app_deploy_fields` partial made it convict ids that are demonstrably on
the page. It now follows `{{template}}` includes transitively. The hazard that buys is a VACUOUS
check, so it ships with a scope decoy — a reference planted in `deploy.html` to an id defined only
in `app_data.html`, a file `deploy.html` does not include — which the gate must still convict. It
does. 53 decoys green.
- **NOT-A-FINDING:** the i18n gate caught „Please" in the new English string (the copy style forbids
pleading); reworded before the commit.
- **FILED: R-1067** — the catalog still carries `pi_compatible:` and nothing reads it. Deliberately
left: a box on 0.314.0 would show „csak x86" on every app. Gated on the fleet floor reaching
0.315.0. (A row and not a session fix: it is another repo and it is blocked on the floor.)
- **FILED + CLOSED: R-1066** — no architecture document covered the app page. Closed by this task's
`02-controller-module-map.md` „The app page" and `10-localisation.md` §7 additions.
- **Capability map:** no row covers „the app page", so none moved. Said rather than assumed.
- **Register size: 156 before → 157 after; 1 opened (R-1067), 1 opened-and-closed (R-1066).**
- **System poster:** no fact in `felhom-system-poster.facts.md` changed (no machine, role, traffic
path, backup tier, time, retention, key or known gap). Neither file edited; nothing added to STATUS.
- **Behaviour change worth naming:** the initial-credentials and default-login cards used to render
only when `HasAppInfo` was true — incidental (the FileBrowser branch set `HasAppInfo = true` by hand
just to make its credentials show). On the settings tab they are no longer gated on catalog copy
existing. A login is not catalog prose.