Files
felhom.eu/scripts/build_sizing_web.py
T
admin d5ef93b13b
gates / gates (push) Successful in 6m42s
Website: the machine estimator (/gepigeny, /en/sizing), live but unlisted; SIZING.md §5 in three verdicts; R-997 filed
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.
2026-10-13 14:31:44 +02:00

138 lines
7.2 KiB
Python

#!/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("&amp;", "&").strip()
def parse_apps_page(path):
t = io.open(path, encoding="utf-8-sig").read()
cats, apps = [], []
for sec in re.finditer(r'<div class="category-section" id="([^"]+)">(.*?)(?=<div class="category-section"|<section class="stats-section")', t, re.S):
cats.append((sec.group(1), text(re.search(r"<h2>(.*?)</h2>", sec.group(2), re.S).group(1))))
for c in re.finditer(r'<div class="app-card" id="([^"]+)">.*?<h3>(.*?)</h3>(?:<span class="app-tagline">(.*?)</span>)?', 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")