app page tuning round 2: P0 cause (Calibre-Web installed before its password field), controller 0.319.0 released, 02 layout rules (style A, login panel, empty-value rule), R-1095 opened, STATUS
gates / gates (push) Successful in 6m54s

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-16 12:41:30 +02:00
parent f1e1c8a60c
commit 59834feadf
35 changed files with 1032 additions and 19 deletions
+38
View File
@@ -0,0 +1,38 @@
# REPORT — app page tuning, round 2 (2026-10-16)
Written in ASD-STE100 Simplified Technical English. The full technical report is felhom-controller `REPORT.md`
(v0.319.0). Evidence: `documentation/audits/app-page-tuning-2026-10-16/` (P0, before/, browser/, release/, tools/,
redproof.txt). Design read and updated: `architecture/02-controller-module-map.md` „The app page's layout".
## 1. Why the Calibre-Web password was empty (Part 0)
Calibre-Web on demo-hp was installed on 2026-08-21. Its template got the password field on 2026-09-29. The box adds new
generated values to installed apps, but never a password. So the box never had one. The user name the box shows is the
real one: a session renamed Calibre-Web's admin to it on 2026-10-01. Nothing was removed. No customer box has this state
(the hub lists only demo-hp and demo-felhom; demo-felhom has no Calibre-Web now — checked on the box).
## 2. What changed on the page
The header, the tab bar and every section are panels (style A). The open tab is filled and underlined. One
„Belépés az alkalmazásba" panel shows the name and the password side by side. A value the box does not have is a
sentence, not an eye. „Cím és kulcsok" is rows: no Domain input, no storage list. The install page uses the same panels;
its fields did not change. Checked in a real (headless) Chrome through `https://felhom.enkisfelhom.hu`.
Login pairs: calibre-web, kimai, lubelogger, nextcloud, paperless-ngx, radicale. 14 other apps show the password alone.
## 3. Release
Controller **0.319.0**: Tester 1, demo-hp and demo-felhom took it through their own floor at 10:34 UTC; box walk checks
green on all three; global floor 0.319.0 / MinAgent **0.158.1** (first saved with 0.157.0 by my mistake, corrected 65
seconds later; no box was affected — see the controller report §6). Per-customer floors cleared.
## 4. Register
Rows before 174, after 175; opened 1 (R-1095: `/apps/{slug}/install` for an installed app), closed 0.
## 5. Other session duties
- Instruction files: no edit.
- System poster: no fact in `felhom-system-poster.facts.md` changed.
- `unproven.py --summary`: NOT WALKED 35 of 55 — no number moved.
- Commits: no Co-Authored-By line (the brief's rule).
+2 -1
View File
@@ -2,7 +2,7 @@
**Ready for the first real tester (Tester-2): yes. Tester 2 (a laptop) is off; nothing was sent to it.**
**Updated 2026-10-16 (morning).** Storage identity build B shipped: agent 0.158.1 and golden 0.318.0 vouched by you, controller 0.318.0 is the floor (MinAgent 0.158.1), installer 1.33.0 published — new one-disk boxes get their own data area. Open items: see `documentation/backlog/OPEN-ITEMS.md`. Older notes, day by day:
**Updated 2026-10-16 (midday).** Controller 0.319.0 (the app page in panels, the login in one panel) is the floor, MinAgent 0.158.1 — `REPORT-app-page-tuning-2026-10-16.md`. **Earlier (morning):** Storage identity build B shipped: agent 0.158.1 and golden 0.318.0 vouched by you, controller 0.318.0 is the floor (MinAgent 0.158.1), installer 1.33.0 published — new one-disk boxes get their own data area. Open items: see `documentation/backlog/OPEN-ITEMS.md`. Older notes, day by day:
`documentation/backlog/STATUS-HISTORY.md`. Reports: `REPORT-night-2026-10-14.md`, `REPORT-immich-rollout-2026-10-14.md`, `REPORT-seo-felho-tarhely-2026-10-14.md`, `REPORT-immich-repair-2026-10-14.md`, `REPORT-power-cut-fixes-2026-10-14.md`, `REPORT-night-power-2026-10-13.md`, `REPORT-poweroff.md`.
## What works
@@ -50,6 +50,7 @@
| D9 | R-1062 | The N100's USB drive is also its whole-box backup drive. Split it in two anyway? | **No — keep one drive**; the lived-in box keeps its nightly whole-box backup | Split: the box loses its local whole-box backup (or add a second drive first) | it stays one drive; multi-drive tests stay on Tester 1 | — |
## Waiting on you — do
- **Nézd meg a Calibre-Web oldalát a demo-hp-n** (`felhom.enkisfelhom.hu`, controller 0.319.0, minden dobozon kiadva). A fejléc, a fülsor és minden rész most külön panel (az „A” stílus). A belépési adatok egy panelben vannak, egymás mellett. A Calibre-Web jelszaváról a doboz nem tud, mert az alkalmazás előbb települt, mint hogy a sablonban jelszó lett volna — ezt most egy mondat mondja ki. Ha nem tetszik, írd meg, mit változtassak. Ha nem nézed meg: semmi nem romlik el.
- **Discard two unclaimed boxes and delete Tester4** (hub, browser only): the build B scratch VMs registered as unclaimed boxes — registration ids 40 (`b43afa10…`) and 41 (`9c78ea30…`); both VMs are destroyed. Tester4 (the throwaway customer) is switched off the household way. If not: two dead entries stay in the Hosts list and Tester4 stays in Customers.
- System poster needs a refresh: one-disk boxes now have a data area („Belső tárhely") on the system disk, outside the whole-guest backups (facts file updated 2026-10-16).
- **Olvasd el a Plex és a Sonarr útmutatót a demo-hp-n** (az alkalmazás oldalán az „Útmutató” fül). Ha más formát szeretnél, egy menetben átírom mind a 62-t. A robot 2026-10-16-án bejelentkezve megnézte: mindkét oldal megjelenik magyarul és angolul, 4 rész és a GYIK, minden sor egyezik a katalógussal. Ha nem nézed meg: csak a forma marad emberi szemmel nem látott.
@@ -477,26 +477,50 @@ does not get to vouch for a version, so an overlay can neither set nor clear it.
**[DESIGN] Guide text is DATA.** Plain text through `html/template` — no markdown, no raw HTML — so
a step containing a tag is shown as the characters its author typed.
#### The app page's layout (v0.316.0, operator-approved mockup 2026-10-15)
#### The app page's layout (v0.319.0, operator-approved 2026-10-16, section style A)
**[DESIGN]** Five rules; the first three are the mockup, the last two are what they imply.
**[DESIGN]** The mockup canvas „Felhom app oldal — egy oszlop" (Settings screen), operator-approved
2026-10-16 with section style **A — panels**. It replaces v0.316.0's hairline sections: with the hero,
the tab bar and every section on the same page background, the operator found the page flat — nothing
told one section from the next.
1. **One column of one width** (820px; the log tab alone is full width, because log lines are long).
Boxes of differing widths read as a page assembled by accident, which is what v0.315.0 looked like.
2. **A section is a hairline, not a card** — `padding: 28px 0` with a bottom `--line-soft`, no
background, no border, no radius. A card says „this is a thing you act on", and most of an app
page is a thing you READ; a card there is a promise the content cannot keep.
3. **A box means something, and there are exactly two.** Blue (`--blue` on `--blue-dim`) when the app
is WAITING for the household — an after-start value; amber (`--warn` on `--warn-dim`) when
something is RISKY, such as a backup sharing a disk with the data it protects. The
exception-colour principle, applied to shape as well as colour. Nothing informational is boxed.
4. **The hero is not a box**: a 64px logo tile, the name, the tagline, hairline chips (state,
lifecycle, update) and meta text (the address, the last automatic update). The tab bar is links
with a 2px `--blue-bright` underline under the open one — no pills, no fills.
5. **A success is not a banner.** The automatic-update SUCCESS line is meta text in the hero. It
asked nothing of the household, so it is a date. Everything that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — stays a banner above the
tabs, which is R-571's placement rule and is unchanged.
Every panel is the column's full width — no narrower inner card. Boxes of differing widths read as
a page assembled by accident.
2. **The hero is a panel** (`--bg-1`, `--line` border, 20px 24px, 16px above it; the logo tile on
`--bg-2`). Its content is unchanged: the name, the tagline, the chips, the meta text.
3. **The tab bar is a panel** (`--bg-1`, 4px inside, 16px above, 20px below). A tab is 10px 18px,
15px/600 in `--text-2`; the open tab is filled `--bg-2` with a 2px `--blue-bright` underline.
4. **Every section is a panel, ONE style on every surface** — the four tabs and the install page,
never mixed: `--bg-1`, `--line` border, `18px 24px 22px`, 16px apart; the title 17px/700 18px from
the top edge with a `--line-soft` rule under it. The install page's form groups (`.form-section`)
and its progress panel are drawn by the same rule. Pinned by `TestAppPage_OneSectionStyleEverywhere`.
5. **Colour still means something.** A panel is neutral. Blue (`--blue` on `--blue-dim`) is the app
WAITING for the household; amber (`--warn` on `--warn-dim`) is something RISKY. The callouts keep
their colours, inside a panel or as their own.
6. **A success is not a banner.** The automatic-update SUCCESS line is meta text in the hero.
Everything that DOES need a decision — a failed update, a hold, lost files, a missing drive, an
interrupted install — stays a banner above the tabs (R-571's placement rule, unchanged).
**[DESIGN] „Belépés az alkalmazásba" — the login in one panel.** The settings tab shows the app's
login name and password side by side (stacked under 720px). A login field is a `type: password`
field plus, when the template has one, the `secret` or `text` field with the same prefix and
`USER`/`USERNAME`/`EMAIL` (`web/app_login.go`); a password with no clean pair stands alone. Values are
fetched on demand (R-254, R-709) — the page learns only whether one is stored.
**[DESIGN] The empty-value rule.** A login field the box holds no value for is a SENTENCE („A doboz
ennél a telepítésnél nem mentett jelszót. A belépéshez azt használd, amit az alkalmazásban
beállítottál."), never an eye: the reveal endpoint answers 404 for it, a refusal the household could
only meet by pressing. The page and the endpoint decide „stored" with ONE function
(`storedValuePresent`). Measured cause of the case that earned it: an app installed before its
template gained a `type: password` field — `InjectMissingFields` never fills one
(`audits/app-page-tuning-2026-10-16/P0`).
**[DESIGN] „Cím és kulcsok" is rows, not the install form.** The settings tab does not re-render the
install wizard's fields: the address is the „Cím" row, the storage is a „Tárhely: <név>" row, every
other install answer and generated key is a row (keys keep their eye). The read-only hint sits under
the title. `/apps/{slug}/install` opened for an installed app still renders the form read-only, with
its secret guards.
**[DESIGN] The stylesheet's address changes with every build.** Every shell links
`/static/style.css?v={{.AssetTag}}`, where the tag hashes version + `GitCommit` + `BuildTime` and
@@ -0,0 +1,57 @@
# Part 0 — why demo-hp's Calibre-Web has no stored password (2026-10-16, read only, no value printed)
Method: SSH to demo-hp (`ssh hp`) → `pct exec 9201`; the controller's own reveal endpoint
(`POST /stacks/calibre-web/auto-field/reveal`, the one the eye button calls) through traefik inside the guest;
Calibre-Web's own user table (`docker exec calibre-web sqlite3 /config/app.db 'select name from user'`), compared
inside the guest. The hub's per-app page (`/apps/calibre-web`) with the CC token; then the box itself
(demo-felhom, `ssh felhom-pve`) as the second channel.
## 1. Keys in calibre-web's app.yaml (9201), names only
```
env: ADMIN_USER DOMAIN HDD_PATH SUBDOMAIN <- no ADMIN_PASSWORD key at all
locked_fields: DOMAIN SUBDOMAIN HDD_PATH ADMIN_USER
deployed_at: 2026-08-21T16:27:08Z
default_login: changed_at 2026-09-29T07:21:06Z by household
app.yaml mtime: 2026-10-01 11:26:10Z <- InjectMissingFields wrote ADMIN_USER here
reveal ADMIN_PASSWORD -> 404, ok false, no value
reveal ADMIN_USER -> 200, value present
```
## 2. Install date vs the template
| what | when |
|---|---|
| calibre-web installed on 9201 | 2026-08-21 16:27Z |
| `ADMIN_PASSWORD` (`type: password`) enters the template | catalog `d0e7e2e`, 2026-09-29 07:19Z |
| `ADMIN_USER` (`type: secret`, `generate: hex:5`) enters the template | catalog `e9f50b5`, 2026-10-01 11:19Z |
| the box injects `ADMIN_USER` into app.yaml | 2026-10-01 11:26:10Z (app.yaml mtime; A3 of `audits/calibre-name-and-prune-2026-10-01`) |
| R-757 guard (no invented after_install-only secret) | controller `1991c74`, v0.298.0, 2026-10-06 |
`InjectMissingFields` (`internal/stacks/deploy.go`) fills only `secret`, `domain` and `subdomain`; a new
`type: password` field falls to the `default:` branch (`requires manual configuration`) and is never stored. So the
**hypothesis holds for the password**: installed before the field existed, never filled, and after_install (which sets
it in the app) never ran for this install.
## 3. Is the stored ADMIN_USER invented?
```
calibre users count 2 admin present False
equal (stored ADMIN_USER is a Calibre-Web user): True
```
**No — on demo-hp the name is NOT invented any more.** On 2026-10-01 11:27Z a session renamed Calibre-Web's admin
user by hand to the name the box had just recorded (`audits/calibre-name-and-prune-2026-10-01/A/A2-demo-hp-rename.txt`,
second block). The name the page shows is the real login name. **So the task's "remove the invented value from
app.yaml on 9201" does not apply: removing it would hide a TRUE login name.** Nothing was removed.
The password that goes with it was set by hand in the app; the operator holds it out-of-band.
## 4. Other boxes (hub, then the box)
The hub's `/apps/calibre-web` says „Deployments 2", affected customers demo-felhom and demo-hp (telemetry). Tester 1
and Tester 2 are not listed. The second channel — the box: demo-felhom guest 9201 has `/opt/docker/stacks/calibre-web/`
with only `.felhom.yml` + `docker-compose.yml` (catalog mirror), **no app.yaml, no calibre-web container** — it is not
installed there now. **No customer box has this state. No register row for customer boxes.**
(The hub's „Deployments 2" counts telemetry history, not what is installed today — a presence-not-success reading;
the box is the truth. NOT-A-FINDING for this task: the hub page labels it telemetry.)
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -0,0 +1,239 @@
after login url: /launcher
{
"settings": {
"path": "/apps/calibre-web/settings",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 3,
"sectionWidths": [
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16
],
"titleTopGap": 19,
"loginPanel": true,
"loginFields": [
"ADMIN_USER",
"ADMIN_PASSWORD"
],
"notStored": [
"ADMIN_PASSWORD"
],
"reveals": [
"ADMIN_USER"
],
"loginPairCols": "377px 377px",
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": []
},
"data": {
"path": "/apps/calibre-web/data",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 4,
"sectionWidths": [
820,
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16,
16
],
"titleTopGap": 19,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": []
},
"guide": {
"path": "/apps/calibre-web/guide",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 10,
"sectionWidths": [
820,
820,
820,
820,
820,
820,
820,
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16,
16,
16,
16,
16,
16,
16,
16
],
"titleTopGap": 19,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": [
[
"log-error",
"Failed to load resource: the server responded with a status of 404 ()",
"https://felhom.enkisfelhom.hu/static/assets/calibre-web-screenshot-3.webp"
]
]
},
"logs": {
"path": "/apps/calibre-web/logs",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 1,
"sectionWidths": [
966
],
"columnWidth": 966,
"sectionSpacing": [],
"titleTopGap": null,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": 916,
"console": []
},
"settings_390px": {
"loginPairCols": "324px",
"scrollWidth": 390
}
}
@@ -0,0 +1,42 @@
after login url: /launcher
{
"install": {
"path": "/apps/grocy/install",
"activeTab": null,
"tabBar": null,
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 2,
"sectionWidths": [
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16
],
"titleTopGap": 19,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": true,
"deployForm": true,
"logBoxWidth": null,
"console": []
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -0,0 +1,231 @@
after login url: /launcher
{
"settings": {
"path": "/apps/paperless-ngx/settings",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 3,
"sectionWidths": [
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16
],
"titleTopGap": 19,
"loginPanel": true,
"loginFields": [
"PAPERLESS_ADMIN_USER",
"PAPERLESS_ADMIN_PASSWORD"
],
"notStored": [],
"reveals": [
"PAPERLESS_ADMIN_PASSWORD"
],
"loginPairCols": "377px 377px",
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": []
},
"data": {
"path": "/apps/paperless-ngx/data",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 4,
"sectionWidths": [
820,
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16,
16
],
"titleTopGap": 19,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": []
},
"guide": {
"path": "/apps/paperless-ngx/guide",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 10,
"sectionWidths": [
820,
820,
820,
820,
820,
820,
820,
820,
820,
820
],
"columnWidth": 820,
"sectionSpacing": [
16,
16,
16,
16,
16,
16,
16,
16,
16
],
"titleTopGap": 19,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": null,
"console": []
},
"logs": {
"path": "/apps/paperless-ngx/logs",
"activeTab": {
"background-color": "rgb(22, 38, 63)",
"color": "rgb(237, 242, 249)",
"border-bottom-color": "rgb(46, 168, 245)",
"border-bottom-width": "2px",
"padding-top": "10px",
"padding-left": "18px"
},
"tabBar": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "4px"
},
"hero": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"padding-top": "20px",
"padding-left": "24px"
},
"section": {
"background-color": "rgb(15, 27, 46)",
"border-top-color": "rgb(34, 52, 79)",
"border-top-width": "1px",
"padding-top": "18px",
"padding-left": "24px",
"margin-bottom": "16px",
"border-radius": "2px"
},
"visibleSections": 1,
"sectionWidths": [
966
],
"columnWidth": 966,
"sectionSpacing": [],
"titleTopGap": null,
"loginPanel": false,
"loginFields": [],
"notStored": [],
"reveals": [],
"loginPairCols": null,
"domainInput": false,
"deployForm": false,
"logBoxWidth": 916,
"console": []
},
"settings_390px": {
"loginPairCols": "324px",
"scrollWidth": 390
}
}
@@ -0,0 +1,26 @@
## TestAppPage_LoginPairPanel — mutation on internal/web/app_login.go
=== RUN TestAppPage_LoginPairPanel
app_page_tuning_test.go:171: the login panel must hold BOTH halves (user=-1 password=301)
--- FAIL: TestAppPage_LoginPairPanel (0.07s)
## TestAppPage_NoStoredValueShowsSentence — mutation on internal/web/app_login.go
=== RUN TestAppPage_NoStoredValueShowsSentence
app_page_tuning_test.go:217: ADMIN_PASSWORD: no sentence for a field the box holds no value for
app_page_tuning_test.go:220: ADMIN_PASSWORD: an eye is offered for a value the endpoint answers 404 for
app_page_tuning_test.go:217: ADMIN_USER: no sentence for a field the box holds no value for
app_page_tuning_test.go:220: ADMIN_USER: an eye is offered for a value the endpoint answers 404 for
app_page_tuning_test.go:225: the page offers an eye for ADMIN_USER and the endpoint answers 404
## TestAppPage_SettingsHasNoDuplicateDomain — mutation on internal/web/templates/app_settings.html
=== RUN TestAppPage_SettingsHasNoDuplicateDomain
app_page_tuning_test.go:242: the settings tab still renders id="auto-field-DOMAIN"
app_page_tuning_test.go:242: the settings tab still renders id="deploy-form"
app_page_tuning_test.go:242: the settings tab still renders id="field-HDD_PATH"
--- FAIL: TestAppPage_SettingsHasNoDuplicateDomain (0.09s)
## TestAppPage_OneSectionStyleEverywhere — mutation on internal/web/templates/app_logs.html
=== RUN TestAppPage_OneSectionStyleEverywhere
app_page_tuning_test.go:379: app_logs: a block outside the section style: <div data-tab-panel="logs">
--- FAIL: TestAppPage_OneSectionStyleEverywhere (0.59s)
## TestDeployPage_DeployedSelectShowsStoredHDDPath — row reads the DEFAULT drive's label
## TestDeployPage_DeployedSelectShowsStoredHDDPath — row reads the DEFAULT drive's label
networkstub_test.go:191: the storage row must name the STORED path's drive, got: data-app-storage-row="1">Tárhely (felhom-usb)
networkstub_test.go:194: the storage row must NOT name the default drive for a deployed app, got: data-app-storage-row="1">Tárhely (felhom-usb)
--- FAIL: TestDeployPage_DeployedSelectShowsStoredHDDPath (0.07s)
@@ -0,0 +1,9 @@
== tester-1
unhealthy=0 restarting=0 exited=0 created=0
controller gitea.dooplex.hu/admin/felhom-controller:0.319.0 Up 58 seconds (healthy)
== demo-hp
unhealthy=0 restarting=0 exited=0 created=0
controller gitea.dooplex.hu/admin/felhom-controller:0.319.0 Up About a minute (healthy)
== demo-felhom
unhealthy=0 restarting=0 exited=0 created=0
controller gitea.dooplex.hu/admin/felhom-controller:0.319.0 Up About a minute (healthy)
@@ -0,0 +1,3 @@
tester-1 Location: /customers/tester-1?flash=floor_set
demo-hp Location: /customers/demo-hp?flash=floor_set
demo-felhom Location: /customers/demo-felhom?flash=floor_set
@@ -0,0 +1,3 @@
felhom.enkisfelhom.hu 200
felhom.enkicsifelhom.hu 200
felhom.demo-felhom.eu 200
@@ -0,0 +1,5 @@
10:37:01
HTTP/1.1 303 See Other
Location: /configuration?flash=floor_set
('min_controller_version', '0.319.0', '2026-10-16 10:37:02')
('min_controller_version_declared_min_agent', '0.319.0=0.158.1', '2026-10-16 10:37:02')
@@ -0,0 +1,3 @@
10:35:57
HTTP/1.1 303 See Other
Location: /configuration?flash=floor_set
@@ -0,0 +1,39 @@
control NEGATIVE (a stub answering 200 over plain http): convicted, as it must (200 -)
== tester-1 (enkicsifelhom.hu): 13 public name(s)
OK control POSITIVE http://felhom.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkicsifelhom.hu/r1033-check?q=1
OK http://files.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://files.enkicsifelhom.hu/r1033-check?q=1
OK http://media.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://media.enkicsifelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1
OK http://paperless.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paperless.enkicsifelhom.hu/r1033-check?q=1
OK http://paste.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paste.enkicsifelhom.hu/r1033-check?q=1
OK http://plex.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://plex.enkicsifelhom.hu/r1033-check?q=1
OK http://prowlarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://prowlarr.enkicsifelhom.hu/r1033-check?q=1
OK http://radarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://radarr.enkicsifelhom.hu/r1033-check?q=1
OK http://sonarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://sonarr.enkicsifelhom.hu/r1033-check?q=1
OK http://torrent.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://torrent.enkicsifelhom.hu/r1033-check?q=1
OK http://transmission.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://transmission.enkicsifelhom.hu/r1033-check?q=1
OK http://wiki.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkicsifelhom.hu/r1033-check?q=1
== demo-hp (enkisfelhom.hu): 13 public name(s)
OK http://books.enkisfelhom.hu/r1033-check?q=1 -> 301 https://books.enkisfelhom.hu/r1033-check?q=1
OK http://docs.enkisfelhom.hu/r1033-check?q=1 -> 301 https://docs.enkisfelhom.hu/r1033-check?q=1
OK http://felhom.enkisfelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkisfelhom.hu/r1033-check?q=1
OK http://files.enkisfelhom.hu/r1033-check?q=1 -> 301 https://files.enkisfelhom.hu/r1033-check?q=1
OK http://gist.enkisfelhom.hu/r1033-check?q=1 -> 301 https://gist.enkisfelhom.hu/r1033-check?q=1
OK http://ha.enkisfelhom.hu/r1033-check?q=1 -> 301 https://ha.enkisfelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1
OK http://papir.enkisfelhom.hu/r1033-check?q=1 -> 301 https://papir.enkisfelhom.hu/r1033-check?q=1
OK http://paste.enkisfelhom.hu/r1033-check?q=1 -> 301 https://paste.enkisfelhom.hu/r1033-check?q=1
OK http://pdf.enkisfelhom.hu/r1033-check?q=1 -> 301 https://pdf.enkisfelhom.hu/r1033-check?q=1
OK http://plex.enkisfelhom.hu/r1033-check?q=1 -> 301 https://plex.enkisfelhom.hu/r1033-check?q=1
OK http://travel.enkisfelhom.hu/r1033-check?q=1 -> 301 https://travel.enkisfelhom.hu/r1033-check?q=1
OK http://wiki.enkisfelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkisfelhom.hu/r1033-check?q=1
== demo-felhom (demo-felhom.eu): 8 public name(s)
OK http://felhom.demo-felhom.eu/r1033-check?q=1 -> 301 https://felhom.demo-felhom.eu/r1033-check?q=1
OK http://files.demo-felhom.eu/r1033-check?q=1 -> 301 https://files.demo-felhom.eu/r1033-check?q=1
OK http://gist.demo-felhom.eu/r1033-check?q=1 -> 301 https://gist.demo-felhom.eu/r1033-check?q=1
OK http://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1 -> 301 https://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1
OK http://prowlarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://prowlarr.demo-felhom.eu/r1033-check?q=1
OK http://radarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://radarr.demo-felhom.eu/r1033-check?q=1
OK http://sonarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://sonarr.demo-felhom.eu/r1033-check?q=1
OK http://torrent.demo-felhom.eu/r1033-check?q=1 -> 301 https://torrent.demo-felhom.eu/r1033-check?q=1
== every public name redirects http -> https
@@ -0,0 +1,4 @@
['key', 'value', 'updated_at']
('min_controller_version', '0.319.0', '2026-10-16 10:35:57')
('artifact_min_agent', '0.155.0', '2026-10-16 07:23:10')
('min_controller_version_declared_min_agent', '0.319.0=0.157.0', '2026-10-16 10:35:57')
@@ -0,0 +1,6 @@
('demo-felhom', '', '')
('demo-hp', '', '')
('tester-1', '', '')
('Tester-2', '', '')
('min_controller_version', '0.319.0', '2026-10-16 10:35:57')
('min_controller_version_declared_min_agent', '0.319.0=0.157.0', '2026-10-16 10:35:57')
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Minimal CDP client: log in to the controller through its PUBLIC address in headless Chrome, open each
app-page tab, assert computed styles, collect console errors, save screenshots. The password is read
from a 0600 file and only ever sent inside a CDP message; it is never printed."""
import base64, json, os, socket, struct, subprocess, sys, time, urllib.request
CHROME = os.path.expanduser('~/.cache/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell')
BASE = sys.argv[1] # https://felhom.enkisfelhom.hu
SLUG = sys.argv[2] # calibre-web
PWFILE = sys.argv[3]
OUT = sys.argv[4]
PORT = 9333
os.makedirs(OUT, exist_ok=True)
prof = os.path.join(OUT, '.profile')
proc = subprocess.Popen([CHROME, f'--remote-debugging-port={PORT}', '--no-sandbox', f'--user-data-dir={prof}',
'--window-size=1280,900', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50):
try:
tabs = json.load(urllib.request.urlopen(f'http://127.0.0.1:{PORT}/json'))
break
except Exception:
time.sleep(0.2)
ws_url = [t for t in tabs if t['type'] == 'page'][0]['webSocketDebuggerUrl']
host, path = ws_url[len('ws://'):].split('/', 1)
h, p = host.split(':')
sock = socket.create_connection((h, int(p)))
key = base64.b64encode(os.urandom(16)).decode()
sock.send(f'GET /{path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
buf = b''
while b'\r\n\r\n' not in buf:
buf += sock.recv(4096)
st = {'pending': buf.split(b'\r\n\r\n', 1)[1]}
def send(obj):
data = json.dumps(obj).encode()
hdr = bytearray([0x81])
n = len(data)
if n < 126: hdr.append(0x80 | n)
elif n < 65536: hdr.append(0x80 | 126); hdr += struct.pack('>H', n)
else: hdr.append(0x80 | 127); hdr += struct.pack('>Q', n)
mask = os.urandom(4); hdr += mask
sock.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv_exact(n):
while len(st['pending']) < n:
st['pending'] += sock.recv(1 << 20)
out, st['pending'] = st['pending'][:n], st['pending'][n:]
return out
def recv():
msg = b''
while True:
b0, b1 = recv_exact(2)
n = b1 & 0x7f
if n == 126: n = struct.unpack('>H', recv_exact(2))[0]
elif n == 127: n = struct.unpack('>Q', recv_exact(8))[0]
msg += recv_exact(n)
if b0 & 0x80: return json.loads(msg)
console = []
mid = [0]
def call(method, **params):
mid[0] += 1; i = mid[0]
send({'id': i, 'method': method, 'params': params})
while True:
m = recv()
if m.get('method') == 'Runtime.exceptionThrown':
console.append(('exception', m['params']['exceptionDetails'].get('text', ''), m['params']['exceptionDetails'].get('exception', {}).get('description', '')[:200]))
elif m.get('method') == 'Runtime.consoleAPICalled' and m['params']['type'] in ('error', 'warning'):
console.append((m['params']['type'], ' '.join(str(a.get('value', a.get('description', ''))) for a in m['params']['args'])[:200]))
elif m.get('method') == 'Log.entryAdded' and m['params']['entry']['level'] in ('error', 'warning'):
e = m['params']['entry']; console.append(('log-' + e['level'], e.get('text', '')[:200], e.get('url', '')))
if m.get('id') == i:
if 'error' in m: raise RuntimeError(f'{method}: {m["error"]}')
return m.get('result', {})
def ev(expr):
r = call('Runtime.evaluate', expression=expr, returnByValue=True, awaitPromise=True)
return r.get('result', {}).get('value')
def goto(url):
call('Page.navigate', url=url)
for _ in range(100):
time.sleep(0.2)
if ev('document.readyState') == 'complete':
break
time.sleep(1.5) # images' onerror, layout
for d in ('Page', 'Runtime', 'Log', 'Network'):
call(d + '.enable')
pw = open(PWFILE).read().strip()
goto(BASE + '/login')
ev("(function(){var f=document.querySelector('input[type=password]'); f.value=%s; f.form.submit(); return 1})()" % json.dumps(pw))
del pw
time.sleep(3)
print('after login url:', ev('location.pathname'))
console.clear() # the login page is not under test
report = {}
for tab in ['settings', 'data', 'guide', 'logs']:
n0 = len(console)
goto(f'{BASE}/apps/{SLUG}/{tab}')
res = ev('''(function(){
function cs(el, props){ if(!el) return null; var s=getComputedStyle(el), o={}; props.forEach(function(p){o[p]=s.getPropertyValue(p)}); return o; }
var secs=[].slice.call(document.querySelectorAll('.app-section')).filter(function(e){return e.offsetParent!==null});
var col=document.querySelector('.app-page');
var t=document.querySelector('.app-section h3'); var first=secs[0];
var gap = (t && first) ? Math.round(t.getBoundingClientRect().top - t.closest('.app-section').getBoundingClientRect().top) : null;
var widths = secs.map(function(e){return Math.round(e.getBoundingClientRect().width)});
var spacing = []; for (var i=1;i<secs.length;i++){ spacing.push(Math.round(secs[i].getBoundingClientRect().top - secs[i-1].getBoundingClientRect().bottom)); }
return {path: location.pathname,
activeTab: cs(document.querySelector('.app-tab-active'), ['background-color','color','border-bottom-color','border-bottom-width','padding-top','padding-left']),
tabBar: cs(document.querySelector('.app-tabs'), ['background-color','border-top-color','padding-top']),
hero: cs(document.querySelector('.app-hero'), ['background-color','border-top-color','padding-top','padding-left']),
section: cs(first, ['background-color','border-top-color','border-top-width','padding-top','padding-left','margin-bottom','border-radius']),
visibleSections: secs.length, sectionWidths: widths, columnWidth: col ? Math.round(col.getBoundingClientRect().width) : null,
sectionSpacing: spacing, titleTopGap: gap,
loginPanel: !!document.querySelector('[data-login-panel]'),
loginFields: [].map.call(document.querySelectorAll('[data-login-field]'), function(e){return e.getAttribute('data-login-field')}),
notStored: [].map.call(document.querySelectorAll('[data-login-not-stored]'), function(e){return e.getAttribute('data-login-not-stored')}),
reveals: [].map.call(document.querySelectorAll('[data-login-reveal]'), function(e){return e.getAttribute('data-login-reveal')}),
loginPairCols: (function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})(),
domainInput: !!document.querySelector('#auto-field-DOMAIN, #field-DOMAIN'), deployForm: !!document.querySelector('#deploy-form'),
logBoxWidth: (function(){var l=document.querySelector('.logs-container'); return l?Math.round(l.getBoundingClientRect().width):null})()
};})()''')
res['console'] = console[n0:]
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
open(os.path.join(OUT, f'{SLUG}-{tab}.png'), 'wb').write(base64.b64decode(shot['data']))
report[tab] = res
# phone width: the login pair stacks
call('Emulation.setDeviceMetricsOverride', width=390, height=844, deviceScaleFactor=1, mobile=True)
goto(f'{BASE}/apps/{SLUG}/settings')
report['settings_390px'] = {'loginPairCols': ev("(function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})()"),
'scrollWidth': ev('document.documentElement.scrollWidth')}
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
open(os.path.join(OUT, f'{SLUG}-settings-390px.png'), 'wb').write(base64.b64decode(shot['data']))
print(json.dumps(report, indent=1, ensure_ascii=False))
finally:
proc.terminate()
subprocess.run(['rm', '-rf', prof])
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Minimal CDP client: log in to the controller through its PUBLIC address in headless Chrome, open each
app-page tab, assert computed styles, collect console errors, save screenshots. The password is read
from a 0600 file and only ever sent inside a CDP message; it is never printed."""
import base64, json, os, socket, struct, subprocess, sys, time, urllib.request
CHROME = os.path.expanduser('~/.cache/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell')
BASE = sys.argv[1] # https://felhom.enkisfelhom.hu
SLUG = sys.argv[2] # calibre-web
PWFILE = sys.argv[3]
OUT = sys.argv[4]
PORT = 9333
os.makedirs(OUT, exist_ok=True)
prof = os.path.join(OUT, '.profile')
proc = subprocess.Popen([CHROME, f'--remote-debugging-port={PORT}', '--no-sandbox', f'--user-data-dir={prof}',
'--window-size=1280,900', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50):
try:
tabs = json.load(urllib.request.urlopen(f'http://127.0.0.1:{PORT}/json'))
break
except Exception:
time.sleep(0.2)
ws_url = [t for t in tabs if t['type'] == 'page'][0]['webSocketDebuggerUrl']
host, path = ws_url[len('ws://'):].split('/', 1)
h, p = host.split(':')
sock = socket.create_connection((h, int(p)))
key = base64.b64encode(os.urandom(16)).decode()
sock.send(f'GET /{path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
buf = b''
while b'\r\n\r\n' not in buf:
buf += sock.recv(4096)
st = {'pending': buf.split(b'\r\n\r\n', 1)[1]}
def send(obj):
data = json.dumps(obj).encode()
hdr = bytearray([0x81])
n = len(data)
if n < 126: hdr.append(0x80 | n)
elif n < 65536: hdr.append(0x80 | 126); hdr += struct.pack('>H', n)
else: hdr.append(0x80 | 127); hdr += struct.pack('>Q', n)
mask = os.urandom(4); hdr += mask
sock.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv_exact(n):
while len(st['pending']) < n:
st['pending'] += sock.recv(1 << 20)
out, st['pending'] = st['pending'][:n], st['pending'][n:]
return out
def recv():
msg = b''
while True:
b0, b1 = recv_exact(2)
n = b1 & 0x7f
if n == 126: n = struct.unpack('>H', recv_exact(2))[0]
elif n == 127: n = struct.unpack('>Q', recv_exact(8))[0]
msg += recv_exact(n)
if b0 & 0x80: return json.loads(msg)
console = []
mid = [0]
def call(method, **params):
mid[0] += 1; i = mid[0]
send({'id': i, 'method': method, 'params': params})
while True:
m = recv()
if m.get('method') == 'Runtime.exceptionThrown':
console.append(('exception', m['params']['exceptionDetails'].get('text', ''), m['params']['exceptionDetails'].get('exception', {}).get('description', '')[:200]))
elif m.get('method') == 'Runtime.consoleAPICalled' and m['params']['type'] in ('error', 'warning'):
console.append((m['params']['type'], ' '.join(str(a.get('value', a.get('description', ''))) for a in m['params']['args'])[:200]))
elif m.get('method') == 'Log.entryAdded' and m['params']['entry']['level'] in ('error', 'warning'):
e = m['params']['entry']; console.append(('log-' + e['level'], e.get('text', '')[:200], e.get('url', '')))
if m.get('id') == i:
if 'error' in m: raise RuntimeError(f'{method}: {m["error"]}')
return m.get('result', {})
def ev(expr):
r = call('Runtime.evaluate', expression=expr, returnByValue=True, awaitPromise=True)
return r.get('result', {}).get('value')
def goto(url):
call('Page.navigate', url=url)
for _ in range(100):
time.sleep(0.2)
if ev('document.readyState') == 'complete':
break
time.sleep(1.5) # images' onerror, layout
for d in ('Page', 'Runtime', 'Log', 'Network'):
call(d + '.enable')
pw = open(PWFILE).read().strip()
goto(BASE + '/login')
ev("(function(){var f=document.querySelector('input[type=password]'); f.value=%s; f.form.submit(); return 1})()" % json.dumps(pw))
del pw
time.sleep(3)
print('after login url:', ev('location.pathname'))
console.clear() # the login page is not under test
report = {}
for tab in ['install']:
n0 = len(console)
goto(f'{BASE}/apps/{SLUG}/{tab}')
res = ev('''(function(){
function cs(el, props){ if(!el) return null; var s=getComputedStyle(el), o={}; props.forEach(function(p){o[p]=s.getPropertyValue(p)}); return o; }
var secs=[].slice.call(document.querySelectorAll('.app-section, .deploy-form .form-section')).filter(function(e){return e.offsetParent!==null});
var col=document.querySelector('.app-page');
var t=document.querySelector('.deploy-form .form-section h4'); var first=secs[0];
var gap = (t && first) ? Math.round(t.getBoundingClientRect().top - t.closest('.app-section, .form-section').getBoundingClientRect().top) : null;
var widths = secs.map(function(e){return Math.round(e.getBoundingClientRect().width)});
var spacing = []; for (var i=1;i<secs.length;i++){ spacing.push(Math.round(secs[i].getBoundingClientRect().top - secs[i-1].getBoundingClientRect().bottom)); }
return {path: location.pathname,
activeTab: cs(document.querySelector('.app-tab-active'), ['background-color','color','border-bottom-color','border-bottom-width','padding-top','padding-left']),
tabBar: cs(document.querySelector('.app-tabs'), ['background-color','border-top-color','padding-top']),
hero: cs(document.querySelector('.app-hero'), ['background-color','border-top-color','padding-top','padding-left']),
section: cs(first, ['background-color','border-top-color','border-top-width','padding-top','padding-left','margin-bottom','border-radius']),
visibleSections: secs.length, sectionWidths: widths, columnWidth: col ? Math.round(col.getBoundingClientRect().width) : null,
sectionSpacing: spacing, titleTopGap: gap,
loginPanel: !!document.querySelector('[data-login-panel]'),
loginFields: [].map.call(document.querySelectorAll('[data-login-field]'), function(e){return e.getAttribute('data-login-field')}),
notStored: [].map.call(document.querySelectorAll('[data-login-not-stored]'), function(e){return e.getAttribute('data-login-not-stored')}),
reveals: [].map.call(document.querySelectorAll('[data-login-reveal]'), function(e){return e.getAttribute('data-login-reveal')}),
loginPairCols: (function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})(),
domainInput: !!document.querySelector('#auto-field-DOMAIN, #field-DOMAIN'), deployForm: !!document.querySelector('#deploy-form'),
logBoxWidth: (function(){var l=document.querySelector('.logs-container'); return l?Math.round(l.getBoundingClientRect().width):null})()
};})()''')
res['console'] = console[n0:]
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
open(os.path.join(OUT, f'{SLUG}-{tab}.png'), 'wb').write(base64.b64decode(shot['data']))
report[tab] = res
print(json.dumps(report, indent=1, ensure_ascii=False)); raise SystemExit
call('Emulation.setDeviceMetricsOverride', width=390, height=844, deviceScaleFactor=1, mobile=True)
goto(f'{BASE}/apps/{SLUG}/settings')
report['settings_390px'] = {'loginPairCols': ev("(function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})()"),
'scrollWidth': ev('document.documentElement.scrollWidth')}
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
open(os.path.join(OUT, f'{SLUG}-settings-390px.png'), 'wb').write(base64.b64decode(shot['data']))
print(json.dumps(report, indent=1, ensure_ascii=False))
finally:
proc.terminate()
subprocess.run(['rm', '-rf', prof])
+2 -1
View File
@@ -124,7 +124,7 @@ stopping line that lies.
| **R-494** | Install & onboarding | P4 | **NARROWED 2026-09-14 by operator ruling → [P3-LOW] the hub COULD create the tunnel at customer creation, for a domain already on Cloudflare. Not blocking: every customer has their own domain and the operator creates the tunnel per day-0 A.1 (`architecture/01-topology-and-trust.md`).** *Original finding, kept:* **[P1-HIGH] A new customer's dashboard has NO reachable address unless the operator hand-makes a Cloudflare tunnel — the link in the setup-code mail is dead.** MEASURED 2026-09-14 on a fresh install from the public ISO (drill intervention **I1**): the claim mail points at `https://felhom.drill0242.felhom.eu`; that name has **no A and no AAAA** record (`dig @1.1.1.1`, control `felhom.enkisfelhom.hu` resolves); the hub has **no tunnel- or DNS-creation code** (`hub/internal/cloudflare/` holds only geo-rule removal; `cf_tunnel_token` is a pasted, optional form field, `configs.go:1478`) — day-0 runbook A.1 makes it a manual Cloudflare-dashboard step that nothing on the customer-create page asks for; the box's own split-horizon resolver on the appliance LAN IP answered `google.com` but not the dashboard name at 13:27:39Z; the agent applied the record at **13:27:44Z** (`lanresolver: applied split-horizon record … ip=192.168.0.158`, 3 m 46 s after the controller started), so the box CAN answer the name — **but only to a device that uses the box as its DNS server, and no document, screen or mail tells a household to do that**; the router and the installer-offered DNS answer nothing. The page was reachable only at the guest's LAN address with the name forced (`curl --resolve …:443:192.168.0.158`). **A volunteer could not have done that.** **What it needs:** an operator ruling — the hub creates the tunnel and DNS at customer creation, or the product gives a household a LAN address that works with no DNS change. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: operator-side automation; the operator creates the tunnel by hand per the day-0 runbook.** | — | — | CC |
| **R-504** | Install & onboarding | P4 | **[P3-LOW] `iso.felhom.eu` cannot show an index page on its own — its root returns 404, and the download page lives on the website instead.** MEASURED 2026-09-14: `https://iso.felhom.eu/` and `/index.html` → 404; only named objects answer. The host is an R2 bucket behind a custom domain; whether R2 would serve an uploaded `index.html` at `/` was **not measured** (uploading anything to the public bucket is a publication). The ISO v1.27.0 task puts the Hungarian download page at `felhom.eu/letoltes` (published with the ISO, after the operator's yes). **Remaining:** a redirect from `iso.felhom.eu/` to that page needs a Cloudflare rule the session has no credential for. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (Cloudflare rule)** **Re-ranked 2026-10-03: P3→P4: households are sent to the website's download page; the bare address is cosmetic.** | — | — | operator |
## Apps & catalog — 20 rows (P2 2, P3 11, P4 7)
## Apps & catalog — 21 rows (P2 2, P3 11, P4 8)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -148,6 +148,7 @@ stopping line that lies.
| **R-995** | Apps & catalog | P4 | **Removing an app through the product can leave unnamed Docker volumes and an empty import folder behind.** MEASURED 2026-10-13 on demo-felhom after 61 removals (`documentation/audits/sizing-2026-10-13/METHOD.md` „Teardown”, `evidence/leftover-volumes-after-bench.txt`): 8 unnamed volumes (4–72 KB; immich 1, romm 2, privatebin 3, onlyoffice 2 — volumes the images declare themselves) and `userdata/import/{paperless,calibre,grimmory}` (empty, created by the deploy). The removal answered 200 and its own leftover check was clean, because it looks only at labelled volumes. No data risk; disk and tidiness only. Removed by name on the bench. | **READY** — filed 2026-10-13 | — | Make the removal account for the containers' unnamed volumes (and an empty import folder it created), with a test that deploys an image that declares a VOLUME | CC |
| **R-1067** | Apps & catalog | P4 | **The catalog still carries `pi_compatible:` on every template, and nothing reads it any more.** controller v0.315.0 removed the „Pi kompatibilis" badge and the „csak x86" tag from every page (Raspberry Pi is no longer supported, so one was noise and the other was a warning on almost every app). The `ResourceHints.PiCompatible` struct field and the catalog key were deliberately LEFT: a box still on 0.314.0 reads the key, and removing it would make that box show „csak x86" on every app. **Gated on the floor:** once the fleet floor is at or above 0.315.0, drop `pi_compatible:` from the templates and the field from `stacks.ResourceHints` in the same pair of commits. Harmless until then — an unread key costs nothing. | **BLOCKED** — filed 2026-10-15 | the fleet floor reaching 0.315.0 | Drop the catalog key and the struct field; grep the hub and the website for Pi wording at the same time (none found 2026-10-15) | CC |
| **R-1043** | Apps & catalog | P4 | **Sonarr, Radarr and Prowlarr build their login redirect as an absolute `http://` URL.** They trust forwarded headers only from their „Trusted networks” (the household set `192.168.0.0/24`; traefik is `172.18.x`), so they ignore `X-Forwarded-Proto: https` and answer `Location: http://<name>/login` to an https visitor. Since controller v0.312.0 that hop is sent back to https by traefik (a GET, no password), so it is cosmetic; a template could seed traefik's network as trusted. (`audits/https-only-2026-10-14/``1A-3-4.txt`) | **OPEN** — filed 2026-10-14 | — | Seed the docker network as a trusted proxy in the *arr templates (with the R-1030 seed), measure the redirect | CC |
| **R-1095** | Apps & catalog | P4 | **`/apps/{slug}/install` still opens for an app that is INSTALLED, and shows the install form read-only.** Found 2026-10-16 (app page tuning, controller v0.319.0): the settings tab stopped rendering the install form, but `appInstallPageHandler` renders `deploy` for any app, so a typed or stale `/install` address shows „<app> — Telepítés" with disabled fields and no button. Its secret guards hold (R-254/R-709 tests; parity cases `deploy_installed*`). | OPEN | the task forbade route changes | a 302 from `/apps/{slug}/install` to `/apps/{slug}/settings` for an installed app (the `redirectStackDeployGET` shape); then the deployed-only branches of `app_fields.html` can go | CC |
## App updates — 4 rows (P3 4)