docs(report): v0.147.0-.3 feedback slice 1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
@@ -1,87 +1,170 @@
|
||||
# REPORT — most recent implementation
|
||||
# REPORT — controller v0.147.0 → v0.147.3: feedback slice 1 (2026-07-19)
|
||||
|
||||
## felhom-controller v0.146.0 — nav polish: styled scrollbars + collapsible sidebar groups — 2026-07-18
|
||||
**Baseline v0.146.0 → LIVE v0.147.3 on demo guest 9201.** Green at every version:
|
||||
`go build ./... && go vet ./... && go test ./...` (23 packages) plus `template_id_gate`,
|
||||
`emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`.
|
||||
|
||||
**Commits:** `24d23b8` (feature) + `fd93020` (render tests). **UI-only** — no behavioural, backup or
|
||||
restore surface touched. **Deployed and live on demo guest 9201** (`0.146.0 Up (healthy)`).
|
||||
> `docker_run_volume_path_gate` fails on `internal/appexport/estimate.go:179`. **Pre-existing on
|
||||
> HEAD and untouched by this release** — verified by stashing this work and re-running the gate.
|
||||
> Not fixed here (out of scope, and it is someone's call whether that `-v :ro` du probe gets an
|
||||
> allowlist entry or a rewrite).
|
||||
|
||||
**Green:** `go build ./... && go vet ./... && go test ./...` all pass. Design-v2 gates
|
||||
`template_id_gate`, `emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `mojibake_gate`,
|
||||
`app_row_dedup_gate` — **all PASS**.
|
||||
## The problem
|
||||
|
||||
### 1. Scrollbars (`style.css`)
|
||||
The systemic complaint, twice in one evening: **you press a button and nothing happens.** No
|
||||
progress, no ETA, no named result. Three worst offenders fixed on the two patterns already in the
|
||||
codebase — the deploy 3-step panel and the storage-init status poll. **No new framework**; that is
|
||||
now ROADMAP **R-45**, because three targeted cards were worth shipping tonight and a framework was
|
||||
not.
|
||||
|
||||
The platform default is a light, chunky bar that reads as a bright stripe against the navy and
|
||||
competes with the content it is scrolling. Now thin and hairline-coloured: `scrollbar-width: thin` +
|
||||
`scrollbar-color` for Firefox **and** `::-webkit-scrollbar` (8px, thumb `--line`, hover `--text-3`,
|
||||
`--radius`) for WebKit/Blink — both declared, because neither alone covers the browsers customers
|
||||
actually use. The two surfaces that really scroll take their own panel background as the track
|
||||
(`.sidebar` → `--bg-2`, `html` → `--bg-0`) so the gutter never shows through as a lighter channel.
|
||||
Tokens only, no raw hexes, no inline styles.
|
||||
Three of the four versions below exist because the cards were watched against **real runs on the
|
||||
demo box**. Each follow-up is a thing no unit test would have surfaced.
|
||||
|
||||
### 2. Collapsible nav groups (`layout.html` + `style.css`, vanilla JS)
|
||||
---
|
||||
|
||||
Tárhely, Biztonsági mentés and Megosztás are accordions with a chevron indicator; **exactly one open
|
||||
at a time**, and clicking the open one closes it. Groups without sub-items (Vezérlőpult,
|
||||
Alkalmazások, Rendszermonitor, Debug) are untouched plain links. Hungarian labels unchanged. No nav
|
||||
restructuring beyond the accordion behaviour; no design-token changes.
|
||||
## 4a — a verification restore names its result
|
||||
|
||||
Four decisions worth recording:
|
||||
The completion flash said the app had been restored „ellenőrző mappába a meghajtón". *Which* folder,
|
||||
on *which* drive, was invisible — so the customer could not go and look at the thing they had just
|
||||
asked for. And nothing anywhere listed what these restores had accumulated, so copies piled up and
|
||||
the only way to find them was SSH.
|
||||
|
||||
- **The header is a real `<button>`**, so keyboard and assistive-tech reachability come for free
|
||||
rather than being simulated with `tabindex`/`role` on a div. `aria-expanded` + `aria-controls` +
|
||||
a `:focus-visible` outline.
|
||||
- **Nothing became unreachable when the header stopped being a link.** Every group's own landing
|
||||
page is *also* its first sub-item (`/storage` → Meghajtók, `/backups` → Áttekintés, `/sharing` →
|
||||
Hálózati megosztás). This was checked before the conversion, not assumed — and it is now asserted
|
||||
by a test, because if someone later drops one of those sub-links the destination becomes
|
||||
unreachable **silently**, the header still looking clickable.
|
||||
- **Progressive enhancement.** The group containing the active page is rendered open **server-side**
|
||||
(`.is-open`), so the correct group is open before any JS executes and stays open if JS never runs.
|
||||
The listener only handles clicks.
|
||||
- **No layout jump.** The collapse animates `grid-template-rows: 0fr → 1fr` (with `min-height: 0` +
|
||||
`overflow: hidden` on the sub-list) rather than `max-height`. That animates to the content's REAL
|
||||
height, so there is no magic number to drift when a group gains or loses an item — the specific way
|
||||
a `max-height` accordion rots. The toggle reserves its 3px active border as `transparent` so
|
||||
becoming active adds no width shift. Transitions are .18s, and both the collapse and the chevron
|
||||
rotation are disabled under `prefers-reduced-motion: reduce`.
|
||||
- The flash now carries the **full path**.
|
||||
- The restore page gained **„Meglévő ellenőrző másolatok"** — app · size · date · path — each with a
|
||||
double-confirmed **„Másolat törlése"** (the house inline `felhomConfirm` idiom, never native
|
||||
`confirm()`).
|
||||
- `backups/offsite-restore` was open-coded in three places; it now has one home
|
||||
(`offsiteRestoreRootFor`). A test pins the path in the flash to the path in the listing, so the
|
||||
customer can never be told about a directory the page cannot show or remove.
|
||||
|
||||
### 3. Verification — and what could not be verified
|
||||
**The delete is the only one this release adds**, so it names a **stack, never a path**: the Manager
|
||||
resolves that name inside a root it computed itself and refuses anything landing outside.
|
||||
Red-proofed — neutralise the name guard and `stack: ""` resolves to the offsite-restore **root** and
|
||||
`os.RemoveAll` takes every copy with it. Every refusal is asserted as a **non-effect** (the
|
||||
neighbouring copy and the live data are still on disk afterwards).
|
||||
|
||||
**The screenshot leg was not done, and the reason is worth recording.** The demo controller's
|
||||
password is **customer-owned** since the claim flow — Viktor set it as customer zero during the
|
||||
2026-07-18 rehearsal — so the credentials on the build server are stale. A curl-login against the
|
||||
live container returns HTTP 200 with `<title>Bejelentkezés — Felhom</title>` and sets no session
|
||||
cookie, i.e. the login is refused. The visual leg needs Viktor's browser.
|
||||
Honest note: the `HasPrefix` containment check behind that name guard could **not** be red-proofed
|
||||
independently — with `isSafeStackName` in front of it, no input the API accepts can reach it with an
|
||||
escaping path. It is kept as defence-in-depth and labelled in the test file as unproven-by-test
|
||||
rather than dressed up as a red-proof it does not have.
|
||||
|
||||
Rather than assert nothing, `internal/web/nav_accordion_test.go` pins the **server-side half** —
|
||||
the part a screenshot would confirm only for whichever page happened to be open — rendering through
|
||||
the real shared layout via the same `loadTemplates()` path the server uses:
|
||||
### Live (endpoint-exact, through the real handlers)
|
||||
|
||||
| Test | Property |
|
||||
|---|---|
|
||||
| `TestNavGroup_ActiveGroupRendersOpenServerSide` | all 7 sub-pages open their own group, with `aria-expanded=true` and an `.active` toggle, and **exactly one** group open — the count is asserted, not just the group we expected |
|
||||
| `TestNavGroup_PageOutsideAnyGroupOpensNothing` | a top-level page (dashboard) forces nothing open |
|
||||
| `TestNavGroup_EveryGroupLandingPageSurvivesAsASubItem` | each group's landing page still exists as a sub-link — the property the `<a>`→`<button>` conversion depended on |
|
||||
| `TestNavGroup_HeaderIsARealButtonWithAControlsTarget` | the toggle is a real button and its `aria-controls` targets an element that exists |
|
||||
Real verification restore of `calibre-web`, then the guard matrix:
|
||||
|
||||
**Red-proofed:** removing `{{if $storageOpen}} is-open{{end}}` from `layout.html` fails both the
|
||||
open-group assertion and the exactly-one-open count on `storage` and `storage-network`, then passes
|
||||
again when restored.
|
||||
```
|
||||
message: "A(z) calibre-web visszaállítva ellenőrző mappába:
|
||||
/mnt/felhom-drives/hdd_1/backups/offsite-restore/calibre-web
|
||||
(a meglévő adatok változatlanok)."
|
||||
listing: calibre-web · 430.4 KB · …/offsite-restore/calibre-web · [Másolat törlése]
|
||||
|
||||
Independently, the deployed 0.146.0 container was confirmed to be **serving the new stylesheet**
|
||||
(the `nav-group-toggle` / `scrollbar-width` rules are present in the CSS it returns), so the live
|
||||
image really does carry this change.
|
||||
stack=../primary -> refused; copy AND backups/primary both still present
|
||||
missing confirm -> refused; nothing changed
|
||||
real delete -> copy gone; backups/primary SURVIVES; page -> „Nincs ellenőrző másolat"
|
||||
```
|
||||
|
||||
### 4. Pre-existing, deliberately not bundled
|
||||
---
|
||||
|
||||
`docker_run_volume_path_gate.py` still fails on `internal/appexport/estimate.go:179`. That is
|
||||
ROADMAP **R-29**, unrelated to this change, verified to fail identically on the untouched tree — and
|
||||
R-29 itself says not to bundle its fix into an unrelated feature commit.
|
||||
## 4b — Megosztás enable shows what it is waiting for
|
||||
|
||||
### 5. Fleet note — the golden still bakes 0.143.0
|
||||
Enabling sharing ran `ReconcileSamba()` **synchronously inside the POST handler**. On a box whose
|
||||
golden had not baked `felhom-samba` that is `compose up -d` pulling ~100MB from a private registry:
|
||||
minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything came up.
|
||||
|
||||
v0.146.0 is live on the demo box, but the golden image was **not** rebuilt this session (deferred
|
||||
rather than half-run — it is a nested-VM snapshot-revert procedure). So a freshly installed box
|
||||
would land on 0.143.0 and self-update. Tracked on the felhom.eu ROADMAP pre-invite checklist, whose
|
||||
target moved from 0.145.x to **0.146.0**.
|
||||
Detached + polled now, with a card that distinguishes **„képfájl letöltése"** (image genuinely
|
||||
absent — the multi-minute case) from **„indítás"** (already baked — seconds). The distinction is
|
||||
decided *before* the work starts, because afterwards the image is always present and the card could
|
||||
never truthfully say „letöltés".
|
||||
|
||||
- Success is **probed, not inferred** — `compose up -d` exits 0 on a crash-loop, so the terminal
|
||||
state is container liveness.
|
||||
- `nil` from reconcile also covers "deliberately deployed nothing because there is no household
|
||||
password yet"; that now gets its own message instead of a card spinning forever.
|
||||
- The **password** form starts the same job — with `UserSet` false reconcile deploys nothing, so on a
|
||||
fresh box *that*, not the enable toggle, is where the pull actually happens.
|
||||
|
||||
**Test scope, stated honestly:** the unit tests cover the single-flight slot, snapshot isolation and
|
||||
the phase vocabulary. They do **not** drive `runSambaEnsureJob` end-to-end — the Server field is the
|
||||
concrete `*stacks.Manager`, not an interface, and introducing one purely for this card was more churn
|
||||
than the slice warranted. Pulling-vs-starting and the probed terminal state are covered by live
|
||||
validation instead. That seam belongs to R-45.
|
||||
|
||||
Live: `/sharing/status` returns `{"ok":true,"data":{"phase":"running","running":true,"error":""}}`;
|
||||
the page carries the card and the poll.
|
||||
|
||||
---
|
||||
|
||||
## 4c — „Távoli mentés most" streams real progress
|
||||
|
||||
restic was already reporting bytes and percentages; the runner seam used `CombinedOutput()` and threw
|
||||
them away. The manual run now passes `--json`, scans stdout line-by-line, and the page shows total
|
||||
bytes, percent and the app being pushed. **Manual only** — the nightly stays silent, pinned by a test
|
||||
that fails if the scheduled path ever passes `--json` or publishes progress.
|
||||
|
||||
The poll also now **arms unconditionally**. It used to start only if the page already rendered
|
||||
„Fut…", which loses a race the manual trigger always runs: the POST redirects and the page renders
|
||||
before the detached goroutine writes `LastStatus=running`, so the poll never armed and the customer
|
||||
watched a static page during the very run they had just started.
|
||||
|
||||
Red-proofed twice: break the parser -> the percent assertion fails; drop the wiring -> the `--json`
|
||||
assertion fails.
|
||||
|
||||
### What three live runs actually taught (v0.147.1 -> .3)
|
||||
|
||||
**Run 1 — immich reported `0%` for 40+ seconds.** The parser was not broken. On an incremental run
|
||||
where nothing changed restic transfers nothing: `bytes_done` is `omitempty`, so it is not even in the
|
||||
JSON, and `percent_done` stays 0. Confirmed by capturing the real schema from
|
||||
`backup --dry-run --json` on restic 0.14.0 in the controller image rather than guessing; those lines
|
||||
are now quoted verbatim in the type's doc comment. **A byte-only bar is indistinguishable from a hang
|
||||
in the common case** — shipping it would have traded "no feedback" for "feedback that says 0% and
|
||||
looks stuck". -> **v0.147.1** parses and publishes `files_done`/`total_files`.
|
||||
|
||||
**Run 2 — bookstack showed clean byte progress (100%, 154.0 MB, 7/7), immich sat at `1 of 46 files,
|
||||
0 bytes` for 42 seconds.** restic 0.14 only counts a file when it **completes**, so an app dominated
|
||||
by one large archive (immich's ~430MB volume tar) freezes *both* counters. No percentage can move.
|
||||
-> **v0.147.2** shows what restic *does* keep reporting: the file being processed and elapsed
|
||||
seconds. „1 / 46 fájl (430.2 MB) · feldolgozás alatt: immich_upload.tar · 42 mp" is a different
|
||||
message from „0%".
|
||||
|
||||
**Run 3 — the per-app legs finished in ~15s; the remaining 40 of a 57-second run was the shares leg
|
||||
and `forget --prune`,** during which the card sat frozen on „calibre-web — 8 / 8 fájl". The same
|
||||
silence, relocated to the end of the run. -> **v0.147.3** adds a run **phase**; the post-app stages
|
||||
announce themselves and app-scoped counters clear when a phase starts.
|
||||
|
||||
Final live trace — the whole 57-second run is narrated, no frozen state:
|
||||
|
||||
```
|
||||
t+6s app=immich
|
||||
t+9s app=bookstack
|
||||
t+12s app=calibre-web
|
||||
t+15s phase=shares
|
||||
t+18s ... t+54s phase=retention
|
||||
t+57s done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Also: golden/controller infra-image drift closed at the source
|
||||
|
||||
`infra.Images()` derives the list from the existing pins; `--print-infra-images` prints it; the golden
|
||||
bake now asks the controller binary it is about to bake instead of carrying its own copy. That copy
|
||||
had already drifted — `felhom-samba` was never added to it, so the golden baked 3 of 4, **which is
|
||||
why enabling Megosztás pulled at runtime in the first place**. A test parses the const block out of
|
||||
the source with `go/ast` and fails if a pin is added without reaching `Images()`; red-proofed.
|
||||
|
||||
Verified against the real published image on the live box — all four returned.
|
||||
|
||||
## Validation method
|
||||
|
||||
Live validation was **endpoint-exact** (the exact URLs the UI posts to, through the real handlers and
|
||||
the real restic/samba paths), not browser click-through: the controller UI is password-gated and I do
|
||||
not enter credentials into web forms. No server logic was skipped — only rendering. The rendered
|
||||
markup was checked by fetching the pages themselves and asserting on the emitted HTML.
|
||||
|
||||
## Not done
|
||||
|
||||
- **Floor not raised** — Viktor decides floor timing.
|
||||
- No golden rebuild, no manifest saves, nothing on Peti's arc.
|
||||
- No SMB server-side changes — the `\\FELHOM` access issue stays client-side diagnosis first.
|
||||
- ROADMAP: **R-45** (unified async-job feedback) and **R-46** (verification-copy browse + expiry)
|
||||
registered in `felhom.eu/documentation/backlog/ROADMAP.md`.
|
||||
|
||||
Reference in New Issue
Block a user