From d5ef93b13b02dd2d67084f8b0141813480e2b881 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 13 Oct 2026 14:31:44 +0200 Subject: [PATCH] =?UTF-8?q?Website:=20the=20machine=20estimator=20(/gepige?= =?UTF-8?q?ny,=20/en/sizing),=20live=20but=20unlisted;=20SIZING.md=20?= =?UTF-8?q?=C2=A75=20in=20three=20verdicts;=20R-997=20filed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A household ticks apps and sees, for an 8, 16 and 32 GB box: fits well / fits, but tight / does not fit, the totals, and the apps that burst on CPU. One rule in assets/sizing.js, every number from assets/sizing-data.json (built by scripts/build_sizing_web.py from the 2026-10-13 N100 measurement; test: 7 cases, red-proofed). Unlisted: noindex, robots Disallow, not in the sitemap, linked from nowhere — site_gates.py gate 25 enforces it (UNLISTED twin pairs keep every twin check). SIZING.md §5 corrected: the 8 GB line contradicted its own x1.5 rule; guest sizes 4.0/11.4/27.4 GB with the measured 612 MiB MemTotal shortfall. R-993 gains the fact that the installer accepts a host below any minimum. --- STATUS.md | 6 +- .../audits/sizing-2026-10-13/SIZING.md | 25 +- documentation/backlog/OPEN-ITEMS.md | 5 +- scripts/CHANGELOG.md | 16 + scripts/build_sizing_web.py | 137 +++ scripts/site_gates.py | 50 +- scripts/test_build_sizing_web.py | 114 +++ website/CHANGELOG.md | 21 +- website/assets/sizing-data.json | 941 ++++++++++++++++++ website/assets/sizing.css | 41 + website/assets/sizing.js | 183 ++++ website/en/sizing.html | 143 +++ website/gepigeny.html | 143 +++ website/robots.txt | 3 + 14 files changed, 1813 insertions(+), 15 deletions(-) create mode 100644 scripts/build_sizing_web.py create mode 100644 scripts/test_build_sizing_web.py create mode 100644 website/assets/sizing-data.json create mode 100644 website/assets/sizing.css create mode 100644 website/assets/sizing.js create mode 100644 website/en/sizing.html create mode 100644 website/gepigeny.html diff --git a/STATUS.md b/STATUS.md index c7b96e99..5f0fe313 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,7 +2,11 @@ **Ready for the first real tester (Tester-2): yes. Tester 2 (a laptop) is off; nothing was sent to it.** -**Updated 2026-10-12 (evening): demo-hp, demo-felhom and Tester 1 run agent 0.154.0 and controller 0.309.0. The open-items list is at 124 (2026-10-13; the newest rows are the sizing bench, R-993..R-995, and a push-hook test defect, R-996). Reports: `REPORT-usenet-2026-10-12.md`, `REPORT-newapps-2026-10-12.md`, `REPORT-disk-evening-2026-10-12.md`, `REPORT-answers-2026-10-12.md`, `REPORT-m3-2026-10-11.md`, `REPORT-redesign-2026-10-10.md`, `REPORT-testers-2026-10-10.md`, `REPORT-register-shrink-2026-10-10.md`, `REPORT-new-apps-2026-10-10.md`, `REPORT-release-2026-10-10.md`, `REPORT-break-the-circle-2026-10-09.md`, `REPORT-dooplex-survival-2026-10-09.md`, `REPORT-day4-2026-10-09.md`.** +**Updated 2026-10-12 (evening): demo-hp, demo-felhom and Tester 1 run agent 0.154.0 and controller 0.309.0. The open-items list is at 125 (2026-10-13; the newest row is the machine estimator to try, R-997). Reports: `REPORT-usenet-2026-10-12.md`, `REPORT-newapps-2026-10-12.md`, `REPORT-disk-evening-2026-10-12.md`, `REPORT-answers-2026-10-12.md`, `REPORT-m3-2026-10-11.md`, `REPORT-redesign-2026-10-10.md`, `REPORT-testers-2026-10-10.md`, `REPORT-register-shrink-2026-10-10.md`, `REPORT-new-apps-2026-10-10.md`, `REPORT-release-2026-10-10.md`, `REPORT-break-the-circle-2026-10-09.md`, `REPORT-dooplex-survival-2026-10-09.md`, `REPORT-day4-2026-10-09.md`.** + +## Tuesday 2026-10-13 (afternoon): the machine estimator is built — live, but unlisted + +- **Waiting on you — decide:** try https://felhom.eu/gepigeny (English: https://felhom.eu/en/sizing), tick a few apps, and say „list it” or what to change (R-997). If you do nothing: the page stays unlisted and nobody finds it. ## Tuesday 2026-10-13: what a Felhom box needs — measured diff --git a/documentation/audits/sizing-2026-10-13/SIZING.md b/documentation/audits/sizing-2026-10-13/SIZING.md index 8233af58..4411798e 100644 --- a/documentation/audits/sizing-2026-10-13/SIZING.md +++ b/documentation/audits/sizing-2026-10-13/SIZING.md @@ -159,8 +159,23 @@ and the page cache is what makes a photo library feel fast. **Disk, why:** the installer gives the guest a 32 GB system volume and, from the free space, a 70 / 130 / 250 GB data volume; the host keeps three daily guest dumps (19 GB on this box). Image sizes per app were **not measured** in this run. -**What each size runs** (the installer gives the guest: 8 GB → 4 GB, 16 GB → ≈ 11.6 GB, 32 GB → ≈ 27.6 GB): -- **8 GB:** the household set **without Immich** (peaks 2.0 GB + platform) with room to spare; Immich only on its own or with - one or two light apps (its import alone peaks at 3.1 GB of the guest's 4 GB). The floor of the offer. -- **16 GB:** the whole household set at peak (≈ 5.5 GB) twice over, plus a dozen light apps. The recommended box. -- **32 GB:** the household set plus most of the catalog: all 62 installable apps typical together ≈ 13.3 GB, at peak ≈ 21.3 GB. +**What each size runs — three verdicts, the same rule as the estimator page** (`/gepigeny`, `website/assets/sizing.js`). +*Corrected 2026-10-13:* the lines here first said an 8 GB box runs Immich „on its own” while this section sizes a box at +peak × 1.5 — which Immich alone does not meet on 8 GB ((470 + 3,061) × 1.5 ≈ 5.2 GB > 4 GB), though its peak fits +(3.4 GB). Three verdicts say both honestly. The guest is the installer's rule on MemTotal = label − 612 MiB (the +16 GB N100 reports 15,772 MiB): **8 GB → 4.0 GB, 16 GB → 11.4 GB, 32 GB → 27.4 GB** (the first version said 11.6 / 27.6). +**Fits well:** peak × 1.5 ≤ guest · **fits, but tight:** peak ≤ guest < peak × 1.5 — works, but a large photo import or +a backup at the same time can slow it down · **does not fit:** guest < peak. Peak = 470 MB platform + the apps' peaks summed. + + +| Selection | Peak | 8 GB | 16 GB | 32 GB | +|---|---|---|---|---| +| Household set | 5.4 GB | does not fit | fits well | fits well | +| Household set without Immich | 2.4 GB | fits well | fits well | fits well | +| Immich alone | 3.4 GB | fits, but tight | fits well | fits well | +| All 62 apps | 21.3 GB | does not fit | does not fit | fits, but tight | + + +- **8 GB:** the household set without Immich fits well; Immich alone fits, but tight; the whole household set does not fit. +- **16 GB:** the whole household set fits well. +- **32 GB:** the household set fits well; all 62 apps at once fit, but tight. diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index a45085ef..ad9bcc21 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -112,7 +112,7 @@ match what the reader sees is how an instrument stops being believed (R-421). It **nothing was proven on the day this line was drawn** and a stopping line that moves a status is a stopping line that lies. -## Install & onboarding — 7 rows (P3 5, P4 2) +## Install & onboarding — 8 rows (P3 6, P4 2) | ID | Category | Sev | What | State | Blocked on | Next action | Owner | |---|---|---|---|---|---|---|---| @@ -120,7 +120,8 @@ stopping line that lies. | **R-250** | Install & onboarding | P3 | **A customer create can fail fail-closed because the host-key scan ladder is shorter than the DNS/AAAA settle time.** Found 2026-08-07 creating the fifth walk's venue. `POST /configs/new` with off-site enabled provisions the Storage Box sub-account and then scans its SSH host key to pin it — **fail-closed by design** (`offsite.go:111-121`, *"don't serve a descriptor the controller can't verify"*), with `defaultScanBackoff` = 2+4+8+16+30 ≈ **60 s**, sized by its own comment to *"the observed DNS propagation lag"*. **Measured, both halves:** the first create exhausted the ladder — five `no such host`, then `dial tcp [2a01:4f8:bacc:2:200::d30]:23: connect: network is unreachable` (the name had just begun resolving, **AAAA-first, into a pod with no IPv6 route**) — and the hub logged `[ERROR] offsite provision for walk5`. An **identical second POST succeeded** ~70 s later on its own final rung (`shared already provisioned … (subaccount 285351)`). **Total settle ≈ 100 s against a 60 s budget.** The endpoint was never the problem: verified afterwards from both the node and the hub pod, by name, `SSH-2.0-OpenSSH_9.6p1`. **Two distinct things are wrong and should not be merged:** (1) the budget is sized against DNS *existence*, but what actually bit is the **AAAA-before-A** window, a different and longer phenomenon — lengthen the ladder and/or prefer the A record for the scan dial; (2) **the operator is told nothing actionable** — the create fails with a generic error, the remedy is "press it again", and nothing says so. The retry is genuinely safe (idempotent on the label; confirmed afterwards — `one_time_secrets` 1, one sub-account, no double-provision) **but that safety is invisible to the person deciding whether pressing again will double-charge them.** **Severity LOW-MEDIUM:** self-clearing, no data at risk — but it is the first thing a new customer's provisioning does and it fails looking like an outage. | **VERIFY** — **2026-10-12: hub 0.147.0 live** (S29, decision 224): the host-key scan dials IPv4 first, waits ~2 min (was ~1), and when it still runs out says the store was created and it is safe to press Save again (`TestScannerDialsIPv4First`, `TestScanOutOfRetriesSaysSafeToPressAgain`, red-proved). Known limit: the hub Ingress reads for 60 s, so a very slow first try can show a gateway timeout while provisioning finishes in the background (pressing again is safe). **READY** — operator ruling 2026-10-12 (S29, `09` §3 decision 224): yes — the scan prefers IPv4, waits longer, and the error says „safe to press again". **READY** — owner Viktor | — | The next real off-site setup (a new customer): read the hub log for the scan; success or the new message → close | CC | | **R-516** | Install & onboarding | P3 | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. **Added by the i18n spike (2026-09-17, controller v0.247.0):** (12) **six formal („ön") forms in the converted dashboard copy** — „Olvassa be telefonnal" (launcher QR hint), „Biztosan kikapcsolja a megosztást?" (launcher), „Ha újratelepíti … importálnia kell" (the layout's remove-app modal), „Kérjük, vegye fel a kapcsolatot" (backups empty state). They were NOT fixed — a localisation release may not change Hungarian bytes — and are now COUNTED by `controller/scripts/i18n_missing_gate.py` (`HU_FORMAL_CEILING` = 6, a ratchet: a new one convicts, fixing one lowers it). The inventory (`audits/I18N-INVENTORY-2026-09-17.md`) is the list this row closes against in localisation slice 6 (R-561). **Extended 2026-09-17 by slice 1 (controller v0.248.0–v0.250.0):** the converted copy now counts **16** formal forms (`HU_FORMAL_CEILING` 6 → 10 → 12 → 16, each raise stated, Hungarian unchanged by rule) — and the count is an UNDER-count: the gate's stem list sees 4 on the release C pages (storage, drive wizards, debug), while a wider list („írja be”, „adja meg”, „adjon hozzá”, „válassza ki”, „biztosan eltávolítja”, „engedélyezze”, „hozzon létre”, „kattintson”) finds at least **22** keys there alone (login „Adja meg a jelszavát”, the NAS guide, the storage confirms). Widen the stems when this row is worked; the ceiling rises with them. **NARROWED 2026-09-20 by localisation slice 6's walk, item by item** (`audits/i18n-slice6-2026-09-20/R-516-item-by-item.md`): item 2 FIXED (slice 1), items 3 and 6 APP-OWNED (and for an English household item 6 is an advantage), item 11 is a wrong NUMBER rather than a language and needs its own row, item 1 is correct English for an English reader and still open for a Hungarian one. **Items 4, 7, 8, 9 and 10 are NOT DECIDABLE FROM AN ENGLISH WALK** - they are about Hungarian copy quality, a disconnected drive and a full disk, none of which this walk had. **What this row is now waiting for is a HUNGARIAN walk on a box with a second drive**, not another English one; saying it closed would be the kind of closure that makes a register stop meaning anything. **-- 2026-10-08:** the recovery screen's own answers are Go literals in Hungarian only — 14 `renderRecovery` messages in `controller/internal/web/recovery_handlers.go` (seen: the safe default „A művelet nem fejeződött be…" rendered on an English page in a red-proof run). Today's new R-304 sentence is a bundle key in both languages; the 14 old ones are not. | **NARROWED 2026-09-20 - rank P3-LOW; owner: CC; needs a Hungarian walk** **2026-10-05 (burn-down night): items 1 and 4 FIXED on controller `main`** (`d7efa7b`): the menu says „Hibakeresés” (English „Debug”); the storage pages use the te-form (formal-form ceiling 17 → 14; 116 parity fixtures changed by exactly those bytes). Item 2 was already fixed. **Left:** items 5–6 (apps' own screens) and the rest. **2026-10-06: items 1 and 4 DELIVERED** (controller v0.298.0). **2026-10-06 (burn-down night, later): items 7–10 and 12 FIXED on controller `main`** (`e4774e6`, `2e5ef7e`): the disconnect time in local time; one banner per unplugged drive; „nézd meg”; the disk/memory/CPU/temperature banners in the household's language (`health.*` keys; the wire text to the hub unchanged and pinned); the last eleven counted formal forms are te-form (ceiling 14 → 0; 119 parity fixtures changed by exactly those bytes). **Left:** items 5–6 (apps' own screens); about 20 formal forms the gate does not count yet („írja be”, „adja meg”, „hozzon létre”, …) on the debug, storage, network-storage and security pages. Item 11 moved to its own row **R-889**. **2026-10-06 02:40: the bundle has NO formal form left** (controller `4c3c203`, unreleased): 49 sentences in the te-form; the gate's stem list widened (it counted 61 on the previous bundle, 0 now) with decoys and one listed third-person exception. **Left (narrowed):** Felhom-owned formal forms in Go and template literals the gate cannot read yet — the network-storage attach errors, escrow and share handlers, the single-copy notice, a settings refusal, the SMART mail, a fill-watch „Kérjük”, and the setup wizard pages; each moves into the bundle in the te-form. Items 5–6 are the apps' own screens (not ours). **2026-10-06: items 7–10, 12 and the bundle's 49 formal forms DELIVERED** in controller v0.299.0. **2026-10-06 night: the Go-literal leftover moved** (controller main `3d6ba28`, unreleased): 22 Go-literal messages — escrow handler (6), share password page (2), export upload (1), network-storage uid/remove/attach failures (13) — are bundle keys (`api.escrow.*`, `api.share.*`, `api.export.*`, `api.netstorage.*`) in the te-form with English (14 were formal); a detached attach failure renders in the reader's language; the NAS refusal says „Válassz". `TestR516_GoLiteralFormalFormsAreGone` (source scan + positive control), `TestR516_NetAddFailureFollowsTheReader`; red-proofs `audits/night-burndown-2026-10-06/ctrl/R-516-red*.txt`. **Left:** the setup wizard and recovery-info.txt (R-554); the SMART and fill-watch texts (the hub event's message — wire text, needs the household-copy producer shape); items 5–6 (apps' own screens). | — | — | CC | | **R-991** | Install & onboarding | P3 | **Nothing a buyer can read says which machines Felhom installs on, and a quarter of the forum beginners own only machines it cannot install on.** FOUND 2026-10-12 (forum research, `audits/forum-research-2026-10-12/FELHOM.md` §2). Searched `documentation/architecture/`, `website/` (`gyik`, `technologiak`, `letoltes`) and `scripts/felhom-host-install.sh`: no CPU-architecture, RAM, disk or network floor is written anywhere. The download page says only that the installer is built on Proxmox VE 9.2 — x86-64 only (inferred from Proxmox, not stated on the page). Of the 33 forum beginners who name what they own, 8 own only a Synology/QNAP/other NAS box, a router with a USB disk or a Raspberry Pi. The selfh.st survey reading reached the same gap independently (R-989, its recommendation 1). **Needs (operator):** the floor itself (RAM and disk minimum; whether thin clients and old laptops with 4–8 GB are accepted) — then one short section on the download page and in the FAQ. Not changed here: the research task forbade website edits. | **WAITING-ON-OPERATOR** — filed 2026-10-12 | the operator naming the floor | Operator: name the minimum (RAM, disk, x86-64); then CC writes the section on `letoltes.html` and `gyik.html` (both languages). If nothing is done: a buyer with a Pi or a Synology downloads an ISO that cannot run | operator | -| **R-993** | Install & onboarding | P3 | **Operator: approve the minimum machine in `documentation/audits/sizing-2026-10-13/SIZING.md` §5** (the input R-991's hardware page needs). MEASURED 2026-10-13 on the N100 demo box (61 apps deployed and removed through the product, 15 min at rest each; heavy apps with one real action). Proposal: **minimum** x86-64, 4 cores (N100 class), **8 GB**, 256 GB SSD; **recommended 16 GB**, 512 GB SSD; out: ARM / Raspberry Pi, Synology-QNAP, routers, cloud VPS. Worked from the floor (2.5 GB for the whole box with no apps) + Immich, Jellyfin, Nextcloud, Home Assistant, Vaultwarden, Paperless-ngx at their peaks (5.0 GB) + 50 % headroom ≈ 12.3 GB. 8 GB runs that set without Immich; 16 GB runs all of it twice over. Data for the estimator: `sizing.json`. | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's decision | Operator: approve or change §5. If nothing is done: no hardware page; a buyer with a Raspberry Pi can still download the installer (R-991 stays open) | operator | +| **R-993** | Install & onboarding | P3 | **Operator: approve the minimum machine in `documentation/audits/sizing-2026-10-13/SIZING.md` §5** (the input R-991's hardware page needs). MEASURED 2026-10-13 on the N100 demo box (61 apps deployed and removed through the product, 15 min at rest each; heavy apps with one real action). Proposal: **minimum** x86-64, 4 cores (N100 class), **8 GB**, 256 GB SSD; **recommended 16 GB**, 512 GB SSD; out: ARM / Raspberry Pi, Synology-QNAP, routers, cloud VPS. Worked from the floor (2.5 GB for the whole box with no apps) + Immich, Jellyfin, Nextcloud, Home Assistant, Vaultwarden, Paperless-ngx at their peaks (5.0 GB) + 50 % headroom ≈ 12.3 GB. 8 GB runs that set without Immich; 16 GB runs all of it twice over. Data for the estimator: `sizing.json`. **2026-10-13 (estimator session) — the installer accepts a host BELOW this minimum:** `autosize_guest_caps` has no floor on the host, only on the guest; on a 4 GB box (MemTotal ≈ 3.5 GiB) the guest gets host − 1 GB ≈ 2.4 GB and Proxmox keeps ≈ 1 GB, where it used ≈ 2.0 GB on the N100 (SIZING.md §1). Whatever minimum is approved, nothing enforces it at install today. The three-verdict wording of §5 now matches the estimator page (`/gepigeny`, R-997). | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's decision | Operator: approve or change §5. If nothing is done: no hardware page; a buyer with a Raspberry Pi can still download the installer (R-991 stays open) | operator | +| **R-997** | Install & onboarding | P3 | **Operator: try the machine estimator at https://felhom.eu/gepigeny (English: /en/sizing) and say „list it” or what to change.** BUILT 2026-10-13: a household ticks apps and sees, for an 8, 16 and 32 GB box, fits well / fits, but tight / does not fit, the totals, and which apps make the CPU work hard for a short time; numbers from the 2026-10-13 N100 measurement (`documentation/audits/sizing-2026-10-13/`). LIVE but UNLISTED: noindex, `Disallow` in robots.txt, not in the sitemap, linked from nowhere — `site_gates.py` gate 25 keeps it that way. It names no minimum machine (R-993) and gives no power or noise figure. Its JavaScript was never run by CC (no browser or JS engine on DooPlex): the operator's try is its first real run. **To list it** (one website task, after the power readings): move the pair from `UNLISTED` to `TWINS`, drop noindex and the two Disallow lines, add both URLs to the sitemap, link it from the menu and the FAQ. | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's try | Operator: open the link, tick a few apps, then say „list it” or what to change. If nothing is done: the page stays unlisted and nobody finds it | operator | | **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 | diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index ed49eea7..4c8cfe08 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,19 @@ +## 2026-10-13 — `build_sizing_web.py` + `site_gates.py` UNLISTED pairs and gate 25 + +- **`build_sizing_web.py`** builds `website/assets/sizing-data.json` (the machine estimator's data) from + `documentation/audits/sizing-2026-10-13/sizing.json` and the two apps pages. Refuses (exit 1, writes nothing) + when a listed app is missing from `sizing.json` (FileBrowser excepted by name: built in), when a peak is below + its typical figure, or when the two apps pages disagree. `--check` compares the committed file with a fresh build. +- **`test_build_sizing_web.py`** (7 tests): fresh build = committed file; the four cases of SIZING.md §5 give the + expected verdicts; SIZING.md's verdict table agrees with the rule; `sizing.js` carries the rule's shape and none + of its numbers. Red-proofed: a hard-coded 470 in the JS, a changed headroom in the data, and a wrong table cell + each convicted. No JS runtime exists on DooPlex or in CI, so the rule is mirrored in Python on the same data. +- **`site_gates.py`**: `UNLISTED` twin pairs get every twin check (hreflang, globe, canonical, lang, no Hungarian + on the English page) and are excluded from the sitemap requirement; **gate 25** adds what unlisted means — + `noindex`, a `Disallow:` line per URL in `robots.txt`, absent from `sitemap.xml`, linked from no other page. + Red-proofed: each of the four, and a wrong twin alternate, convicted on its own; the first run caught the missing + `robots.txt` lines for real. + ## 2026-10-12 (night) — make_hero_clip.py: scenes keep their own length - **`--seconds` is no longer a fixed window.** Each scene now plays its full recorded length, read diff --git a/scripts/build_sizing_web.py b/scripts/build_sizing_web.py new file mode 100644 index 00000000..51771c1b --- /dev/null +++ b/scripts/build_sizing_web.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""build_sizing_web.py — website/assets/sizing-data.json for the machine estimator page. + + python3 -I scripts/build_sizing_web.py write the file + python3 -I scripts/build_sizing_web.py --check exit 1 if the committed file differs from a fresh build + +Inputs (read only): + documentation/audits/sizing-2026-10-13/sizing.json the 2026-10-13 N100 measurement + website/alkalmazasok.html, website/en/apps.html the apps the site lists, their names and categories + +The page offers EXACTLY the apps the two apps pages list. Refusals (exit 1, nothing written): + - an app listed on a page but absent from sizing.json (FileBrowser excepted, BY NAME: it is built + into every box and its memory is inside the platform figure — SIZING.md §1) + - an app whose peak is below its typical figure + - the two pages listing different apps +The rule's numbers live HERE and in the data file only; website/assets/sizing.js reads them from the +file and has none of its own (scripts/test_build_sizing_web.py checks that). +""" +import hashlib, io, json, os, re, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SIZING = os.path.join(ROOT, "documentation", "audits", "sizing-2026-10-13", "sizing.json") +SOURCE_COMMIT = "c885b5ac" # the commit that added sizing.json; the sha256 below shows when it moved on +HU_PAGE = os.path.join(ROOT, "website", "alkalmazasok.html") +EN_PAGE = os.path.join(ROOT, "website", "en", "apps.html") +OUT = os.path.join(ROOT, "website", "assets", "sizing-data.json") + +# SIZING.md §1/§5: platform inside the guest = its containers (255 MB) + the Docker daemons (≈ 220 MB RSS) +PLATFORM_MB = 470 +HEADROOM = 1.5 +# scripts/felhom-host-install.sh autosize_guest_caps(): guest = clamp(host-4096, min 4096, max host-2048), +# then the ceiling host-1024. `host` is MemTotal, which is below the label: the N100 sold as 16 GB reports +# 15,772 MiB (measured 2026-10-13), so a box of N GB is taken as N*1024 - 612 MiB. +GUEST_RULE = {"reserve_mib": 4096, "max_gap_mib": 2048, "min_mib": 4096, "ceiling_gap_mib": 1024, + "memtotal_shortfall_mib": 612} +BOXES_GB = [8, 16, 32] +BUILTIN = {"filebrowser"} + +# One short line per medium / heavy-in-bursts app (the 9 of 2026-10-13). sizing.json's English notes, tidied. +CPU_NOTES = { + "immich": ("csak amíg új fotókat dolgoz fel (bélyegképek, arcok, keresés): 200 fotó kb. 6 perc", + "only while it processes new photos (thumbnails, faces, search): 200 photos took about 6 minutes"), + "jellyfin": ("csak amíg egy filmet átalakít egy olyan eszköznek, amely azt nem tudja lejátszani", + "only while it converts a film for a device that cannot play it"), + "paperless-ngx": ("csak amíg új beszkennelt dokumentumokat olvas be (szövegfelismerés, kb. 4 oldal percenként)", + "only while it reads new scanned documents (text recognition, about 4 pages a minute)"), + "calibre-web": ("csak amíg új könyveket vesz fel", "only while it imports new books"), + "romm": ("az első indításkor; a játékkönyvtár átnézését nem mértük", "at first start; a library scan was not measured"), + "onlyoffice": ("csak az első indításkor", "only at first start"), + "grimmory": ("csak az első indításkor", "only at first start"), + "tandoor": ("csak az első indításkor", "only at first start"), + "adventurelog": ("csak az első indításkor", "only at first start"), +} + + +def fail(msg): + print("build_sizing_web: REFUSED —", msg) + sys.exit(1) + + +def text(s): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", s)).replace("&", "&").strip() + + +def parse_apps_page(path): + t = io.open(path, encoding="utf-8-sig").read() + cats, apps = [], [] + for sec in re.finditer(r'
(.*?)(?=
(.*?)", sec.group(2), re.S).group(1)))) + for c in re.finditer(r'
.*?

(.*?)

(?:(.*?))?', sec.group(2), re.S): + apps.append(dict(slug=c.group(1), category=len(cats) - 1, name=text(c.group(2)), tagline=text(c.group(3) or ""))) + return cats, apps + + +def build(): + raw = open(SIZING, "rb").read() + sz = json.loads(raw) + S = {a["slug"]: a for a in sz["apps"]} + hu_cats, hu = parse_apps_page(HU_PAGE) + en_cats, en = parse_apps_page(EN_PAGE) + if [a["slug"] for a in hu] != [a["slug"] for a in en] or len(hu_cats) != len(en_cats): + fail("the Hungarian and English apps pages do not list the same apps in the same order") + E = {a["slug"]: a for a in en} + apps = [] + for a in hu: + slug = a["slug"] + rec = dict(slug=slug, name_hu=a["name"], name_en=E[slug]["name"], tagline_hu=a["tagline"], + tagline_en=E[slug]["tagline"], category=hu_cats[a["category"]][0]) + if slug in BUILTIN: + rec.update(builtin=True, typical_mb=0, peak_mb=0, cpu_class="light", cpu_note_hu="", cpu_note_en="", + measured=True) + else: + if slug not in S: + fail("%s is listed on the apps pages but missing from sizing.json" % slug) + s = S[slug] + if s["mem_peak_mb"] is None or s["mem_typical_mb"] is None: + fail("%s has no memory figure in sizing.json" % slug) + if s["mem_peak_mb"] < s["mem_typical_mb"]: + fail("%s: peak %s < typical %s in sizing.json" % (slug, s["mem_peak_mb"], s["mem_typical_mb"])) + cls = s["cpu_class"] + note = CPU_NOTES.get(slug, ("", "")) + if cls in ("medium", "heavy-in-bursts") and not note[0]: + fail("%s is %s but has no CPU note here" % (slug, cls)) + rec.update(typical_mb=s["mem_typical_mb"], peak_mb=s["mem_peak_mb"], cpu_class=cls, + cpu_note_hu=note[0] if cls != "light" else "", cpu_note_en=note[1] if cls != "light" else "", + measured=s["source"] != "unmeasured") + apps.append(rec) + return dict( + schema=1, + measured="2026-10-13", + measured_on="Intel N100 mini PC, 16 GB (demo-felhom)", + source=dict(path="documentation/audits/sizing-2026-10-13/sizing.json", commit=SOURCE_COMMIT, + sha256=hashlib.sha256(raw).hexdigest()), + platform_mb=PLATFORM_MB, + headroom=HEADROOM, + guest_rule=GUEST_RULE, + boxes_gb=BOXES_GB, + categories=[dict(id=h[0], id_en=e[0], name_hu=h[1], name_en=e[1]) for h, e in zip(hu_cats, en_cats)], + apps=apps, + ) + + +def render(d): + return json.dumps(d, ensure_ascii=False, indent=1) + "\n" + + +if __name__ == "__main__": + out = render(build()) + if "--check" in sys.argv: + cur = io.open(OUT, encoding="utf-8").read() if os.path.exists(OUT) else "" + if cur != out: + print("build_sizing_web: website/assets/sizing-data.json is NOT what the builder makes — rebuild it") + sys.exit(1) + print("build_sizing_web: sizing-data.json matches a fresh build") + else: + io.open(OUT, "w", encoding="utf-8", newline="\n").write(out) + print("wrote", os.path.relpath(OUT, ROOT), "-", len(json.loads(out)["apps"]), "apps") diff --git a/scripts/site_gates.py b/scripts/site_gates.py index 2db01128..c386cdc9 100644 --- a/scripts/site_gates.py +++ b/scripts/site_gates.py @@ -109,6 +109,15 @@ URL = {"index.html": "/", "alkalmazasok.html": "/alkalmazasok", "technologiak.ht # English twin is written then, as a twin. NO_TWIN = {"szolgaltatasok-nonpublic.html", "404.html", "adatkezeles.html", "feltetelek.html"} +# UNLISTED (2026-10-13, the machine estimator) — a twin pair that is LIVE but deliberately unlisted until the +# operator says "list it" (R-997). It gets every twin check (hreflang, globe, canonical, lang, no Hungarian on the +# English page), and gate 25 adds what unlisted means: `noindex` on both pages, a `Disallow:` line for each URL in +# robots.txt, absent from sitemap.xml, and linked from no other page. To LIST it: move the pair into TWINS, drop +# noindex and the Disallow lines, add both URLs to the sitemap and a link from the menu — in one commit. +UNLISTED = {"gepigeny.html": os.path.join("en", "sizing.html")} +URL.update({"gepigeny.html": "/gepigeny", os.path.join("en", "sizing.html"): "/en/sizing"}) +ALL_TWINS = dict(TWINS, **UNLISTED) + MOCKUPS = set() # the design studies were deleted with website/preview/ at the M3 switch # SHELL (2026-10-11, M3) — every public page now wears the controller's frame instead of the @@ -138,10 +147,12 @@ SHELL = { os.path.join("en", 'testing.html'), os.path.join("en", 'contact.html'), os.path.join("en", 'download.html'), + 'gepigeny.html', + os.path.join("en", 'sizing.html'), } -PAGES = list(TWINS) + list(TWINS.values()) + sorted(NO_TWIN) + sorted(MOCKUPS) + sorted(SHELL) -EN_PAGES = set(TWINS.values()) +PAGES = list(ALL_TWINS) + list(ALL_TWINS.values()) + sorted(NO_TWIN) + sorted(MOCKUPS) + sorted(SHELL) +EN_PAGES = set(ALL_TWINS.values()) HU_PAGES = set(PAGES) - EN_PAGES - MOCKUPS - SHELL ANALYTICS_EXEMPT = {"szolgaltatasok-nonpublic.html"} | MOCKUPS @@ -425,7 +436,7 @@ def check_globe(p, lang, urls=None): ALT_RE = re.compile(r'') -for hu, en in TWINS.items(): +for hu, en in ALL_TWINS.items(): if hu not in pages or en not in pages: fail("twin pair %s <-> %s: a page of the pair is missing" % (hu, en)) continue @@ -438,7 +449,7 @@ for hu, en in TWINS.items(): canon = re.search(r'', pages[p]) if not canon or canon.group(1) != SITE + URL[p]: fail("%s: canonical %r — it must point at the page itself (%s)" % (p, canon and canon.group(1), SITE + URL[p])) -print(" twins: %d pairs checked both ways (hreflang, globe, canonical)" % len(TWINS)) +print(" twins: %d pairs checked both ways (hreflang, globe, canonical), %d of them unlisted" % (len(ALL_TWINS), len(UNLISTED))) for p in sorted(NO_TWIN): # no twin: the globe's structure, current mark and label still hold if p in pages: check_globe(p, lang_of(p)) @@ -546,7 +557,7 @@ for p in sorted(x for x in pages if is_en(x)): if BEG.search(t): fail("%s: the site does not beg — no \"please\"/\"kindly\": %r" % (p, t[:120])) # the retrieval promise: the English page may make it no more often than its Hungarian twin does - hu = next((h for h, e in TWINS.items() if e == p), None) + hu = next((h for h, e in ALL_TWINS.items() if e == p), None) if hu and hu in pages: en_n = sum(len(re.findall(rx, t, re.I)) for t in visible_strings(s) for rx in _RET_EN) hu_n = sum(fold(t).count(fold(st)) for t in visible_strings(pages[hu]) for st in _RET_HU) @@ -817,7 +828,8 @@ if not os.path.exists(_SITEMAP): else: _sm = io.open(_SITEMAP, encoding="utf-8-sig").read() _locs = [re.sub(r"^https?://felhom\.eu", "", u) for u in re.findall(r"([^<]+)", _sm)] - _want = set(URL.values()) | _LEGAL_URLS + _unlisted_urls = {URL[_p] for _pair in UNLISTED.items() for _p in _pair} + _want = (set(URL.values()) - _unlisted_urls) | _LEGAL_URLS _have = set(_locs) for _u in sorted(_want - _have): fail("sitemap.xml: %s is a public page and is NOT listed — search engines are told the site " @@ -844,6 +856,32 @@ else: if not [f for f in fails if "sitemap.xml" in f]: print(" sitemap: %d URL(s), every public page once, every twin pair cross-named" % len(_locs)) +# --------------------------------------------------------------------------------------------- +# gate 25 (2026-10-13): an UNLISTED pair stays unlisted until the operator lists it (R-997). Live and reachable by its +# link, but: noindex on the page, Disallow in robots.txt, absent from sitemap.xml, and no other page links to it — +# each of the four is what "unlisted" means, and losing any one of them quietly publishes the page. +_ROBOTS = os.path.join(W, "robots.txt") +_robots = io.open(_ROBOTS, encoding="utf-8-sig").read() if os.path.exists(_ROBOTS) else "" +_sm_locs = set(re.sub(r"^https?://felhom\.eu", "", u) for u in re.findall(r"([^<]+)", io.open(_SITEMAP, encoding="utf-8-sig").read())) if os.path.exists(_SITEMAP) else set() +_unl_pages = {p for _pair in UNLISTED.items() for p in _pair} +for _p in sorted(_unl_pages): + _s = pages.get(_p, "") + _u = URL[_p] + _m = re.search(r'', _s) + if not _m or "noindex" not in [x.strip() for x in _m.group(1).split(",")]: + fail("%s: an UNLISTED page without — search engines may show it" % _p) + if not re.search(r"(?m)^Disallow:\s*%s\s*$" % re.escape(_u), _robots): + fail("robots.txt: no 'Disallow: %s' — the UNLISTED page %s is open to crawlers" % (_u, _p)) + if _u in _sm_locs: + fail("sitemap.xml: %s is listed — an UNLISTED page must not be in the sitemap" % _u) + _link = re.compile(r'href="(?:https?://felhom\.eu)?%s(?:[#?"])' % re.escape(_u)) + for _q in sorted(pages): + if _q in _unl_pages: + continue # the pair's own globe, canonical and alternates point at each other + if _link.search(pages[_q]): + fail("%s links to the UNLISTED page %s — it is published by that link" % (_q, _u)) +print(" unlisted: %d page(s) — noindex, robots Disallow, not in the sitemap, linked from no other page" % len(_unl_pages)) + if fails: print("\nSITE GATES FAILED: %d problem(s)" % len(fails)) sys.exit(1) diff --git a/scripts/test_build_sizing_web.py b/scripts/test_build_sizing_web.py new file mode 100644 index 00000000..6d2e3836 --- /dev/null +++ b/scripts/test_build_sizing_web.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Tests for the machine estimator's data and rule (website/assets/sizing-data.json, sizing.js). + +No JavaScript runtime exists on DooPlex or in CI (python3 + git only), so the rule is checked here in Python, on +the SAME data file the page reads, and the JavaScript is held to having no numbers of its own: + 1. the committed data file is exactly what scripts/build_sizing_web.py builds + 2. the four cases of SIZING.md §5 give the expected verdicts by the rule (estimate() below mirrors sizing.js) + 3. the verdict table in SIZING.md §5 says the same as the rule computes + 4. sizing.js carries the rule's shape and none of its constants (they all come from the data file) +Run: python3 -I scripts/test_build_sizing_web.py +""" +import io, json, os, re, sys, unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +import build_sizing_web as B + +DATA = os.path.join(ROOT, "website", "assets", "sizing-data.json") +JS = os.path.join(ROOT, "website", "assets", "sizing.js") +SIZING_MD = os.path.join(ROOT, "documentation", "audits", "sizing-2026-10-13", "SIZING.md") + +HOUSEHOLD = ["immich", "jellyfin", "nextcloud", "home-assistant", "vaultwarden", "paperless-ngx"] +WORDS = {"well": "fits well", "tight": "fits, but tight", "no": "does not fit"} + + +def guest_mib(d, gb): # mirrors sizing.js guestMib() + r = d["guest_rule"] + total = gb * 1024 - r["memtotal_shortfall_mib"] + mem = total - r["reserve_mib"] + if mem > total - r["max_gap_mib"]: + mem = total - r["max_gap_mib"] + if mem < r["min_mib"]: + mem = r["min_mib"] + if mem > total - r["ceiling_gap_mib"]: + mem = total - r["ceiling_gap_mib"] + return mem + + +def estimate(d, slugs): # mirrors sizing.js estimate() + by = {a["slug"]: a for a in d["apps"]} + peak = d["platform_mb"] + sum(by[s]["peak_mb"] for s in slugs if not by[s].get("builtin")) + out = [] + for gb in d["boxes_gb"]: + g = guest_mib(d, gb) + out.append("well" if peak * d["headroom"] <= g else ("tight" if peak <= g else "no")) + return peak, out + + +class SizingWeb(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.d = json.load(io.open(DATA, encoding="utf-8")) + cls.all = [a["slug"] for a in cls.d["apps"] if not a.get("builtin")] + + def test_committed_file_is_a_fresh_build(self): + self.assertEqual(io.open(DATA, encoding="utf-8").read(), B.render(B.build()), + "sizing-data.json differs from a fresh build — run scripts/build_sizing_web.py") + + def test_offers_the_62_listed_apps_plus_builtin(self): + self.assertEqual(len(self.all), 62) + self.assertEqual([a["slug"] for a in self.d["apps"] if a.get("builtin")], ["filebrowser"]) + + def test_guest_sizes_follow_the_installer(self): + self.assertEqual([guest_mib(self.d, gb) for gb in (8, 16, 32)], [4096, 11676, 28060]) + self.assertEqual(guest_mib(self.d, 4), 4 * 1024 - 612 - 1024) # a 4 GB host: the ceiling, not 4 GB + + def test_the_four_cases(self): + cases = { + "household": (HOUSEHOLD, ["no", "well", "well"]), + "household without immich": ([s for s in HOUSEHOLD if s != "immich"], ["well", "well", "well"]), + "immich alone": (["immich"], ["tight", "well", "well"]), + "all 62": (self.all, ["no", "no", "tight"]), + "nothing": ([], ["well", "well", "well"]), + } + for name, (sel, want) in cases.items(): + self.assertEqual(estimate(self.d, sel)[1], want, name) + + def test_sizing_md_table_agrees_with_the_rule(self): + with io.open(SIZING_MD, encoding="utf-8") as f: + s = f.read() + block = re.search(r"(.*?)", s, re.S) + self.assertIsNotNone(block, "SIZING.md has no sizing-verdicts table") + rows = {} + for line in block.group(1).splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) == 5 and cells[0] not in ("Selection", "---"): + rows[cells[0]] = cells + sel = {"Household set": HOUSEHOLD, "Household set without Immich": [s for s in HOUSEHOLD if s != "immich"], + "Immich alone": ["immich"], "All 62 apps": self.all} + self.assertEqual(set(rows), set(sel)) + for name, slugs in sel.items(): + peak, verdicts = estimate(self.d, slugs) + self.assertEqual(rows[name][1], "%.1f GB" % (peak / 1024), name + " peak") + self.assertEqual(rows[name][2:], [WORDS[v] for v in verdicts], name) + + def test_js_has_the_rule_and_none_of_its_numbers(self): + js = io.open(JS, encoding="utf-8").read() + for need in ("data.platform_mb", "data.headroom", "r.reserve_mib", "r.max_gap_mib", "r.min_mib", + "r.ceiling_gap_mib", "r.memtotal_shortfall_mib", "data.boxes_gb", + "peak * data.headroom <= guest", "peak <= guest"): + self.assertIn(need, js) + code = re.sub(r"/\*.*?\*/", "", js, flags=re.S) + code = re.sub(r"(?m)//.*$", "", code) + code = re.sub(r"'(?:[^'\\]|\\.)*'", "''", code) + nums = set(re.findall(r"(? total - r.max_gap_mib) { mem = total - r.max_gap_mib; } + if (mem < r.min_mib) { mem = r.min_mib; } + if (mem > total - r.ceiling_gap_mib) { mem = total - r.ceiling_gap_mib; } + return mem; + } + + function estimate(data, slugs) { + var bySlug = {}; + data.apps.forEach(function (a) { bySlug[a.slug] = a; }); + var typical = data.platform_mb, peak = data.platform_mb, busy = [], unmeasured = []; + slugs.forEach(function (s) { + var a = bySlug[s]; + if (!a || a.builtin) { return; } + typical += a.typical_mb; + peak += a.peak_mb; + if (a.cpu_class === 'medium' || a.cpu_class === 'heavy-in-bursts') { busy.push(a); } + if (!a.measured) { unmeasured.push(a); } + }); + var boxes = data.boxes_gb.map(function (gb) { + var guest = guestMib(data, gb); + var verdict = peak * data.headroom <= guest ? 'well' : (peak <= guest ? 'tight' : 'no'); + return { gb: gb, guest_mb: guest, verdict: verdict }; + }); + var heavy = busy.filter(function (a) { return a.cpu_class === 'heavy-in-bursts'; }); + return { typical_mb: typical, peak_mb: peak, boxes: boxes, busy: busy, heavy: heavy, unmeasured: unmeasured }; + } + + root.FelhomSizing = { estimate: estimate, guestMib: guestMib }; + + /* ---------------------------------------------------------------- the page (drawing only) */ + if (typeof document === 'undefined') { return; } + + function el(tag, cls, txt) { + var e = document.createElement(tag); + if (cls) { e.className = cls; } + if (txt !== undefined) { e.textContent = txt; } + return e; + } + + function icon(name) { + var ns = 'http://www.w3.org/2000/svg'; + var s = document.createElementNS(ns, 'svg'); + s.setAttribute('class', 'ico'); + s.setAttribute('aria-hidden', 'true'); + var u = document.createElementNS(ns, 'use'); + u.setAttribute('href', '/assets/icons.svg?v=1#' + name); + s.appendChild(u); + return s; + } + + function start() { + var host = document.getElementById('sizer'); + var strEl = document.getElementById('sizer-strings'); + if (!host || !strEl) { return; } + var T = JSON.parse(strEl.textContent); + var lang = document.documentElement.lang === 'en' ? 'en' : 'hu'; + var gbText = function (mb) { + var v = (mb / 1024).toFixed(1); + return (lang === 'hu' ? v.replace('.', ',') : v) + ' GB'; + }; + var appsBox = document.getElementById('sizer-apps'); + var result = document.getElementById('sizer-result'); + + fetch(host.getAttribute('data-src')).then(function (r) { return r.json(); }).then(function (data) { + data.categories.forEach(function (c) { + var fs = el('fieldset', 'sizer-group'); + fs.appendChild(el('legend', '', lang === 'en' ? c.name_en : c.name_hu)); + data.apps.filter(function (a) { return a.category === c.id; }).forEach(function (a) { + var lab = el('label', 'sizer-app'); + var cb = el('input'); + cb.type = 'checkbox'; + cb.value = a.slug; + if (a.builtin) { cb.checked = true; cb.disabled = true; } + lab.appendChild(cb); + var name = el('span', 'sizer-app-name', lang === 'en' ? a.name_en : a.name_hu); + lab.appendChild(name); + var tag = a.builtin ? T.builtin : (lang === 'en' ? a.tagline_en : a.tagline_hu); + if (tag) { lab.appendChild(el('span', 'sizer-app-tag', tag)); } + fs.appendChild(lab); + }); + appsBox.appendChild(fs); + }); + + function selected() { + return Array.prototype.map.call(appsBox.querySelectorAll('input[type=checkbox]:checked:not(:disabled)'), + function (c) { return c.value; }); + } + + function draw() { + var r = estimate(data, selected()); + result.textContent = ''; + var totals = el('p', 'sizer-totals'); + totals.appendChild(el('span', '', T.typical + ': ')); + totals.appendChild(el('strong', 'sizer-num', gbText(r.typical_mb))); + totals.appendChild(el('span', '', ' · ' + T.peak + ': ')); + totals.appendChild(el('strong', 'sizer-num', gbText(r.peak_mb))); + result.appendChild(totals); + result.appendChild(el('p', 'sizer-fine', T.platform_note)); + + var ul = el('ul', 'sizer-boxes'); + r.boxes.forEach(function (b) { + var li = el('li', 'sizer-box sizer-' + b.verdict); + var head = el('div', 'sizer-box-head'); + head.appendChild(icon(b.verdict === 'well' ? 'i-check' : (b.verdict === 'tight' ? 'i-triangle-alert' : 'i-x'))); + head.appendChild(el('strong', 'sizer-box-size', b.gb + ' GB')); + head.appendChild(el('span', 'sizer-verdict', T.verdict[b.verdict])); + li.appendChild(head); + li.appendChild(el('p', 'sizer-fine', T.guest.replace('{x}', gbText(b.guest_mb)))); + if (b.verdict === 'tight') { li.appendChild(el('p', 'sizer-why', T.tight_why)); } + ul.appendChild(li); + }); + result.appendChild(ul); + + if (r.busy.length) { + result.appendChild(el('h3', 'sizer-sub', T.cpu_head)); + var cl = el('ul', 'sizer-cpu'); + r.busy.forEach(function (a) { + var li = el('li'); + li.appendChild(el('strong', '', (lang === 'en' ? a.name_en : a.name_hu) + ': ')); + li.appendChild(el('span', '', lang === 'en' ? a.cpu_note_en : a.cpu_note_hu)); + cl.appendChild(li); + }); + if (r.heavy.length >= 2) { + cl.appendChild(el('li', 'sizer-pair', T.heavy_pair.replace('{names}', r.heavy.map(function (a) { + return lang === 'en' ? a.name_en : a.name_hu; + }).join(T.and)))); + } + result.appendChild(cl); + } + if (r.unmeasured.length) { + result.appendChild(el('p', 'sizer-why', T.unmeasured + ': ' + r.unmeasured.map(function (a) { + return lang === 'en' ? a.name_en : a.name_hu; + }).join(', '))); + } + } + + appsBox.addEventListener('change', draw); + Array.prototype.forEach.call(document.querySelectorAll('[data-preset]'), function (btn) { + btn.addEventListener('click', function () { + var want = (btn.getAttribute('data-preset') || '').split(',').filter(Boolean); + Array.prototype.forEach.call(appsBox.querySelectorAll('input[type=checkbox]:not(:disabled)'), function (c) { + c.checked = want.indexOf(c.value) !== -1; + }); + draw(); + }); + }); + host.hidden = false; + draw(); + }).catch(function () { + result.textContent = T.load_error; + host.hidden = false; + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(this); diff --git a/website/en/sizing.html b/website/en/sizing.html new file mode 100644 index 00000000..3796a010 --- /dev/null +++ b/website/en/sizing.html @@ -0,0 +1,143 @@ + + + + + + + Which machine do I need? — an estimate for the apps you pick | Felhom.eu + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ +

Tick what you would run on the box, and the page shows how it fits an 8, 16 or 32 GB machine. This is an estimate from my own measurements on an N100 mini PC (13 October 2026), not a promise.

+ + + + +
+

What the machine needs

+
    +
  • Processor: x86-64 (Intel or AMD), 4 cores, N100 class or newer.
  • +
  • System disk: a 256 GB SSD; 512 GB recommended.
  • +
  • Photos and films go on a separate data drive.
  • +
  • Not supported: Raspberry Pi and other ARM boards, Synology / QNAP, routers, rented cloud servers.
  • +
+
+ How does the page count? +

The box's own services use about 470 MB of memory (Dashboard, tunnel, file manager, Docker). The page adds the measured figures of the apps you tick: once their typical use, once their peak, as if every app were busy at the same moment. That is a deliberately cautious count.

+

On an 8, 16 or 32 GB machine, the Felhom system gets the memory the installer gives it; the rest is for the base system. Fits well: one and a half times the peak still fits. Fits, but tight: the peak fits, one and a half times it does not. Does not fit: not even the peak fits.

+

The figures were measured on an N100 mini PC (13 October 2026): every app installed and at rest, the heavier ones also during one real task (a photo import, a film conversion, text recognition). A larger photo library or more users can need more.

+
+
+ + + +
+ + + diff --git a/website/gepigeny.html b/website/gepigeny.html new file mode 100644 index 00000000..91f95eb1 --- /dev/null +++ b/website/gepigeny.html @@ -0,0 +1,143 @@ + + + + + + + Milyen gép kell? — becslés a kiválasztott alkalmazásokhoz | Felhom.eu + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ +

Jelöld be, mit futtatnál a dobozon, és az oldal megmutatja, hogy egy 8, 16 vagy 32 GB memóriás gépbe hogyan fér bele. Ez becslés a saját mérésemből egy N100-as mini PC-n (2026. október 13.), nem ígéret.

+ + + + +
+

Amit a géptől várok

+
    +
  • Processzor: x86-64 (Intel vagy AMD), 4 mag, N100 kategória vagy újabb.
  • +
  • Rendszerlemez: 256 GB SSD; ajánlott az 512 GB.
  • +
  • A fotók és a filmek külön adatlemezre kerüljenek.
  • +
  • Nem támogatott: Raspberry Pi és más ARM gépek, Synology / QNAP, routerek, bérelt felhőszerverek.
  • +
+
+ Hogyan számol az oldal? +

A doboz saját szolgáltatásai kb. 470 MB memóriát használnak (vezérlőpult, alagút, fájlkezelő, Docker). Ehhez adja hozzá a bejelölt alkalmazások mért értékeit: egyszer az átlagos használatot, egyszer a csúcsot, mintha minden alkalmazás egyszerre dolgozna. Ez szándékosan óvatos számítás.

+

Egy 8, 16 vagy 32 GB-os gépen a Felhom-rendszer annyi memóriát kap, amennyit a telepítő ad neki; a többi az alaprendszeré. „Bőven elfér”: a csúcs másfélszerese is belefér. „Elfér, de szűkösen”: a csúcs belefér, a másfélszerese nem. „Nem fér el”: már a csúcs sem fér bele.

+

A számok egy N100-as mini PC-n mért értékek (2026. október 13.): minden alkalmazás telepítve, nyugalomban, a nehezebbek egy valódi feladat közben is (fotóimport, filmátalakítás, szövegfelismerés). Egy nagyobb fotótár vagy több felhasználó többet kérhet.

+
+
+ + + +
+ + + diff --git a/website/robots.txt b/website/robots.txt index b0e0a8dc..e0f7fff5 100644 --- a/website/robots.txt +++ b/website/robots.txt @@ -1,6 +1,9 @@ User-agent: * Allow: / Disallow: /szolgaltatasok-nonpublic +# The machine estimator is live but unlisted until the operator lists it (R-997). +Disallow: /gepigeny +Disallow: /en/sizing # /api/ is the contact form's POST endpoint, not a page: a GET returns 404 and Search # Console reported it as "Not found (404)" (seen 2026-10-10, first crawled 2026-04-29). Disallow: /api/