92e9d45cf5
gates / gates (push) Successful in 6m29s
The bar keeps its length (the fits-well box memory) and is split into base system (box - guest), Felhom's own services, each app's peak (largest first, the dashboard's palette minus amber and its first blue; beyond 8 apps one group) and spare room (guest - peak) — all from the one rule; a test proves the parts sum to the bar within 1 MB. A legend under it (not sticky) and one sentence explain the recommended size. Typical figure moved into the method fold-out. English: 'an 8 GB machine'.
144 lines
7.7 KiB
Python
144 lines
7.7 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, 12, 16, 24, 32, 48, 64] # the common sizes a recommendation names (v2: 12, 24, 48, 64 added)
|
|
BAR_MARKS_GB = [8, 16, 24, 32, 48, 64] # the memory bar's marks; the last one reads "64+"
|
|
BAR_MAX_GB = 64 # the bar's linear scale is 0..BAR_MAX_GB
|
|
BAR_APP_COLOURS = 8 # v3: apps get their own colour up to this many; the rest join one segment
|
|
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'<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,
|
|
bar_marks_gb=BAR_MARKS_GB,
|
|
bar_max_gb=BAR_MAX_GB,
|
|
bar_app_colours=BAR_APP_COLOURS,
|
|
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")
|