From c658418b1023c02d4f910df719acddd1b04f7dce Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 6 Aug 2026 23:14:24 +0200 Subject: [PATCH] fix(netwerk): koppel niet langer de database aan het gedeelde netwerk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_shared_network hing elke service van een stack aan het gedeelde netwerk. Eén druk op 'koppelen' maakte daarmee de database van die app bereikbaar voor elke andere gekoppelde app — en niet elk sjabloon zet daar een wachtwoord op. - Standaard gaan alleen services mee die geen database, cache of zoekindex zijn; brokers als mosquitto en rabbitmq blijven wel deelbaar. - Per onderdeel te kiezen in de stap 'Verbinden'; de keuze staat in .serverup.json en geldt ook bij een volgende wijziging. - mongodump/mongorestore sturen geen inloggegevens meer mee bij een database zonder authenticatie; die dump mislukte altijd. - Joblogs worden bewaard in SU_JOBS (standaard /data/jobs) en teruggelezen na een herstart; een afgekapte taak meldt dat in plaats van 'unknown'. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT --- AGENTS.md | 11 ++++ CHANGELOG.md | 24 +++++++ CLAUDE.md | 13 ++++ Dockerfile | 2 +- VERSION | 2 +- docker-compose.yml | 3 + server-up/app.py | 70 ++++++++++++++++---- server-up/core/backups.py | 17 +++-- server-up/core/compose_transform.py | 66 +++++++++++++++++-- server-up/core/jobs.py | 99 ++++++++++++++++++++++++++++- server-up/templates/index.html | 48 +++++++++++++- server-up/translations/en.json | 3 + server-up/translations/nl.json | 3 + tests/conftest.py | 1 + tests/test_backups.py | 19 ++++++ tests/test_connect.py | 76 ++++++++++++++++++++-- tests/test_jobs.py | 91 ++++++++++++++++++++++++++ 17 files changed, 515 insertions(+), 33 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 tests/test_jobs.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41e7702 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Projectafspraken +- Werk nooit rechtstreeks op main +- Push naar de dev branch +- Werk met versienummering (v0.0.00) +- In de dev-branch altijd beta weergeven bij de versienummering +- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request. +- Gebruik geen sudo +- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels. +- Voer relevante tests uit voor iedere commit. +- Push wijzigingen naar Forgejo, maar merge niet zelfstandig. +- Bij opdracht "Release versie" maak een versie releaseversie \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa36cd..041d960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# v0.8.24-beta — Koppelen laat je database met rust + +Koppelen aan het gedeelde netwerk hing élke service van een stack eraan, ook de +database. Eén druk op de knop maakte `roosterwijs-db` of `graylog-mongo` op +containernaam bereikbaar voor elke andere gekoppelde app — en lang niet elk +sjabloon zet een wachtwoord op zijn database. + +- **Alleen de app gaat mee, niet zijn opslag.** Databases, caches en + zoekindexen blijven standaard binnen hun eigen stack. Een MQTT-broker of + RabbitMQ hoort daar bewust niet bij: die dééllen is juist waarvoor het + gedeelde netwerk bestaat. +- **Per onderdeel te kiezen.** Wil je één Postgres voor twee stacks, dan zet je + hem in de stap 'Verbinden' aan. De keuze wordt bewaard en blijft gelden bij + een volgende wijziging. +- **Het joblog vertelt wat er gekoppeld is**, in plaats van alleen het netwerk. +- **Een backup van een database zonder wachtwoord werkt weer.** Het + dumpcommando stuurde altijd inloggegevens mee, ook bij een MongoDB zonder + authenticatie — dat mislukte gegarandeerd, terwijl de melding erna 'Backup + klaar' was. +- **Het joblog overleeft een herstart.** Taken stonden alleen in het geheugen; + wie tijdens het bijwerken meekeek zag daarna alleen nog "unknown". De laatste + twintig taken staan nu in `/data/jobs`, en een taak die door een herstart is + afgekapt zegt dat ook. + # v0.8.23-beta — De branch van een repo is te zien en te wijzigen Sjablonen komen uit de git-cache van een app-repo, niet uit het image van diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7941560 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# Projectafspraken +- Werk nooit rechtstreeks op main +- Push naar de dev branch +- Werk met versienummering (v0.0.00) +- Verhoog per uitgave met 0.0.01, dus 0.7.95 → 0.7.96. Geen sprongen van tien, + en de middelste positie blijft één cijfer: na 0.7.99 komt 0.8.00. +- In de dev-branch altijd beta weergeven bij de versienummering +- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request. +- Gebruik geen sudo +- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels. +- Voer relevante tests uit voor iedere commit. +- Push wijzigingen naar Forgejo, maar merge niet zelfstandig. +- Bij opdracht "Release versie" maak een versie releaseversie \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 812c61a..e08cbf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,7 +72,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \ ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \ SU_VERSION=${SU_VERSION} \ SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \ - SU_SECRET=/data/secret.key \ + SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \ GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts" COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh diff --git a/VERSION b/VERSION index 61c94b0..45612bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.23-beta +0.8.24-beta diff --git a/docker-compose.yml b/docker-compose.yml index a6da7f2..ac9cc62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,9 @@ services: - SU_CONFIG=/data/config.json - SU_AUDIT=/data/audit.db - SU_GIT_CACHE=/data/git + # Joblogs overleven hier een herstart; zonder deze map is na het + # bijwerken niet meer terug te zien hoe de taak afliep. + - SU_JOBS=/data/jobs - SU_CONTAINER=server-up # Onder welk account Server Up draait. install.sh vult dit in; leeg of 0 # betekent root. De container krijgt hiermee géén extra rechten — er gaan diff --git a/server-up/app.py b/server-up/app.py index 0fcc997..cd1e55e 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1113,6 +1113,7 @@ def api_stack_config(name): ips=meta.get("ips") or {}, connected=bool(meta.get("connected")), app_networks=meta.get("app_networks") or [], + app_services=meta.get("app_services") or [], source=meta.get("source", ""), repo_id=meta.get("repo_id", ""), name=meta.get("name", name)) @@ -1155,6 +1156,9 @@ def api_stack_reconfigure(name): app_netten = [n for n in (meta.get("app_networks") or []) if n] or ( [gedeeld_net] if meta.get("connected") else []) verbinden = bool(app_netten) + # Welke onderdelen op dat netwerk komen. Zonder keuze blijven databases er + # buiten; zie compose_transform.add_shared_network. + app_diensten = _gekozen_app_diensten(body, meta) # Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack # opleveren — en er ligt al een werkende versie die we niet moeten slopen. @@ -1230,9 +1234,14 @@ def api_stack_reconfigure(name): else: jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}") if klaar: + tekst = cf.read_text(encoding="utf-8") + diensten_nu = app_diensten or \ + compose_transform.deelbare_diensten(tekst) cf.write_text(compose_transform.add_shared_network( - cf.read_text(encoding="utf-8"), klaar), encoding="utf-8") - jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)) + tekst, klaar, services=diensten_nu), encoding="utf-8") + jobs.log(qq, "ok", "Gekoppeld aan " + + ", ".join(f"'{n}'" for n in klaar) + + " (" + ", ".join(diensten_nu) + ")") ok_cfg, err = docker.validate_compose(d) if not ok_cfg: @@ -1247,6 +1256,7 @@ def api_stack_reconfigure(name): "secret_keys": secret_keys, "network": net_name, "ip": net_ip, "ips": net_ips, "app_networks": app_netten, + "app_services": app_diensten, "service_ports": poorten_meta, "web": boilerplates.metadata(bron).get("web") or {}, "connected": bool(app_netten)}) @@ -1284,8 +1294,10 @@ def api_stack_connect(name): lib, d = _stack_dir(name) if not d or not d.exists(): return jsonify(ok=False, msg="niet gevonden"), 404 - aan = bool((request.json or {}).get("connect", True)) + body = request.json or {} + aan = bool(body.get("connect", True)) gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() + app_diensten = _gekozen_app_diensten(body, _stack_meta_lezen(d)) jid, q = jobs.create(f"connect:{name}") # De job draait in een eigen thread; `request` bestaat daar niet meer. @@ -1309,19 +1321,22 @@ def api_stack_connect(name): cf = docker.find_compose(d) origineel = cf.read_text(encoding="utf-8") - cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net), - encoding="utf-8") + diensten_nu = app_diensten or \ + compose_transform.deelbare_diensten(origineel) + cf.write_text(compose_transform.add_shared_network( + origineel, gedeeld_net, services=diensten_nu), encoding="utf-8") ok_cfg, err = docker.validate_compose(d) if not ok_cfg: cf.write_text(origineel, encoding="utf-8") jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}") jobs.finish(jid, "error"); jobs.done(qq); return - _stack_meta_schrijven(d, {"connected": True}) + _stack_meta_schrijven(d, {"connected": True, + "app_services": diensten_nu}) docker.compose_up(d, log_fn=lf, name=name) jobs.log(qq, "ok", - f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu " - "bereikbaar op hun containernaam.") + f"Gekoppeld aan '{gedeeld_net}' ({', '.join(diensten_nu)}) " + "\u2014 die zijn nu bereikbaar op hun containernaam.") audit.log("stacks", "connect", "ok", ref=name, ip=ip) jobs.finish(jid, "done") except Exception as e: @@ -1597,6 +1612,19 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]: return [standaard] if verbinden else [] +def _gekozen_app_diensten(body: dict, meta: dict | None = None) -> list[str]: + """Welke onderdelen van deze stack mogen op het gedeelde netwerk? + + Leeg betekent: laat `add_shared_network` kiezen, en die houdt databases en + zoekindexen erbuiten. Wie zijn database bewust wil delen — één Postgres voor + twee stacks — zet hem hier expliciet bij. + """ + if isinstance(body.get("app_services"), list): + return [s for s in body["app_services"] if isinstance(s, str) and s] + return [s for s in ((meta or {}).get("app_services") or []) + if isinstance(s, str) and s] + + def _geinstalleerde_bronnen() -> dict[str, list[str]]: """Per app-sjabloon de stacks die eruit geïnstalleerd zijn. @@ -1782,10 +1810,15 @@ def api_store_preview(): # afleiden uit de veldnamen gaat mis zodra een onderdeel meer dan één # container heeft. diensten: list[str] = [] + dienst_info: list[dict] = [] try: - doc = yaml.safe_load(preview_files.get("compose.yaml") - or preview_files.get("docker-compose.yml") or "") or {} + compose_tekst = (preview_files.get("compose.yaml") + or preview_files.get("docker-compose.yml") or "") + doc = yaml.safe_load(compose_tekst) or {} diensten = [n for n in (doc.get("services") or {})] + # Mét image en de vraag of het een database is: daarmee laat de + # interface zien wat er wel en niet op het gedeelde netwerk komt. + dienst_info = compose_transform.diensten(compose_tekst) except Exception: diensten = [] return jsonify( @@ -1797,6 +1830,7 @@ def api_store_preview(): compatibility=boilerplates.compatibility(meta), fields=flds, services=diensten, + service_info=dienst_info, depends_on=_afhankelijkheden_met_status(meta), preview_files=preview_files, # Backwards-compat voor de oude UI: @@ -1905,6 +1939,7 @@ def api_store_install(): # schakelaar en dus één netwerk voor álles; nu kun je groepjes maken. gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() app_netten = _gekozen_app_netwerken(d, gedeeld_net) + app_diensten = _gekozen_app_diensten(d) verbinden = bool(app_netten) if not stack: return jsonify(ok=False, msg="stack vereist") @@ -2167,11 +2202,15 @@ def api_store_install(): if klaar: cf = docker.find_compose(dest) try: + tekst = cf.read_text(encoding="utf-8") + diensten_nu = app_diensten or \ + compose_transform.deelbare_diensten(tekst) cf.write_text(compose_transform.add_shared_network( - cf.read_text(encoding="utf-8"), klaar), encoding="utf-8") + tekst, klaar, services=diensten_nu), encoding="utf-8") jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar) - + " — apps daarin zijn bereikbaar op hun containernaam.") + + " (" + ", ".join(diensten_nu) + ") — bereikbaar " + "op hun containernaam.") except compose_transform.TransformError as e: jobs.log(qq, "warn", f"Koppelen mislukt: {e}") @@ -2212,6 +2251,7 @@ def api_store_install(): "ips": net_ips if net else {}, "connected": bool(app_netten), "app_networks": app_netten, + "app_services": app_diensten, "service_ports": poorten_meta, "categories": meta.get("categories") or [], "tags": meta.get("tags") or [], @@ -4109,6 +4149,12 @@ if __name__ == "__main__": print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n") _controleer_assets() audit.init() + # Taken van vóór deze start terughalen. Wie hier tijdens het bijwerken op + # het joblog stond te kijken, ziet zo alsnog hoe het afliep in plaats van + # een venster dat op "unknown" blijft staan. + hersteld = jobs.herstel() + if hersteld: + print(f"Joblog: {hersteld} eerdere taak(en) teruggelezen") i18n.load() print("Boot repos:") _apply_boot_repos() diff --git a/server-up/core/backups.py b/server-up/core/backups.py index c214876..1b66207 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -237,9 +237,12 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]: hulp = "mariadb-dump" if s == "mariadb" else "mysqldump" return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction", db or gb], {}) - return (["sh", "-c", - f"mongodump --archive --username='{gb or 'root'}' " - f"--password='{ww}' --authenticationDatabase=admin"], {}) + # Zonder wachtwoord draait deze MongoDB zonder authenticatie (zoals het + # Graylog-sjabloon doet). Dan toch inloggegevens meesturen levert gegarandeerd + # "Authentication failed" op — en dus nooit een dump van die stack. + inlog = (f"--username='{gb or 'root'}' --password='{ww}' " + "--authenticationDatabase=admin ") if ww else "" + return (["sh", "-c", f"mongodump --archive {inlog}".rstrip()], {}) def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]: @@ -800,9 +803,11 @@ def _herstel_commando(dienst: dict) -> list[str]: if s in ("mariadb", "mysql"): hulp = "mariadb" if s == "mariadb" else "mysql" return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"] - return ["sh", "-c", - f"mongorestore --archive --drop --username='{gb or 'root'}' " - f"--password='{ww}' --authenticationDatabase=admin"] + # Zie _dump_commando: zonder wachtwoord draait deze MongoDB zonder + # authenticatie, en dan werken inloggegevens juist averechts. + inlog = (f"--username='{gb or 'root'}' --password='{ww}' " + "--authenticationDatabase=admin ") if ww else "" + return ["sh", "-c", f"mongorestore --archive --drop {inlog}".rstrip()] def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool: diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index 7796a69..882818d 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -116,8 +116,52 @@ def _laad(compose_text: str) -> dict: return doc -def add_shared_network(compose_text: str, network) -> str: - """Hang elke service ook aan een of meer gedeelde netwerken. +# Opslag- en zoekdiensten horen bij één stack. Een MQTT-broker of RabbitMQ +# staat er bewust niet tussen: die dééllen tussen stacks is juist waarvoor het +# gedeelde netwerk bestaat. +_HULPDIENST_IMAGES = ( + "postgres", "postgis", "pgvector", "timescaledb", "mysql", "mariadb", + "mongo", "redis", "valkey", "keydb", "dragonfly", "memcached", + "elasticsearch", "opensearch", "clickhouse", "influxdb", "meilisearch", + "typesense", "qdrant", "solr", "etcd", +) + + +def is_hulpdienst(image: str) -> bool: + """Is dit een database of zoekindex die alleen bij zijn eigen app hoort?""" + kaal = (image or "").split("/")[-1].split(":")[0].lower() + return kaal.startswith(_HULPDIENST_IMAGES) + + +def diensten(compose_text: str) -> list[dict]: + """De services met hun image en of ze een hulpdienst zijn. + + De interface laat hiermee kiezen wat er op het gedeelde netwerk komt, zonder + dat je het compose-bestand hoeft te lezen. + """ + try: + doc = yaml.safe_load(compose_text) or {} + except yaml.YAMLError: + return [] + uit = [] + for naam, svc in (doc.get("services") or {}).items(): + if not isinstance(svc, dict): + continue + image = str(svc.get("image") or "") + uit.append({"name": naam, "image": image, + "helper": is_hulpdienst(image), + "host_network": bool(svc.get("network_mode"))}) + return uit + + +def deelbare_diensten(compose_text: str) -> list[str]: + """Welke services standaard op het gedeelde netwerk horen.""" + return [d["name"] for d in diensten(compose_text) + if not d["helper"] and not d["host_network"]] + + +def add_shared_network(compose_text: str, network, services=None) -> str: + """Hang services ook aan een of meer gedeelde netwerken. `network` mag een naam zijn of een lijst namen. Meerdere netwerken maken groepjes mogelijk: media-apps onderling, en de rest daarbuiten. @@ -125,8 +169,13 @@ def add_shared_network(compose_text: str, network) -> str: Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken — een app als Zigbee2MQTT vindt de MQTT-broker uit een andere stack dus niet. - Door ze allemaal aan één gedeeld bridge-netwerk te hangen, lost de - container-naam wél op tussen stacks. + Door ze aan één gedeeld bridge-netwerk te hangen, lost de container-naam wél + op tussen stacks. + + `services` bepaalt welke; zonder opgave gaan alleen de services mee die geen + database of zoekindex zijn. Die hebben buiten hun eigen stack niets te + zoeken, en er staan er genoeg zonder wachtwoord op het interne netwerk — + die zou je met een druk op 'koppelen' voor elke andere app openzetten. Bestaande netwerken (zoals een macvlan met vast IP) blijven staan; dit komt er naast. @@ -143,10 +192,17 @@ def add_shared_network(compose_text: str, network) -> str: if not namen: return compose_text + if services is None: + gekozen = set(deelbare_diensten(compose_text)) + else: + gekozen = {s for s in services if s} + aangesloten = 0 - for svc in doc["services"].values(): + for snaam, svc in doc["services"].items(): if not isinstance(svc, dict): continue + if snaam not in gekozen: + continue # Een service met `network_mode` deelt al de namespace van de host of # van een andere container. Docker compose weigert die combinatie: # "declares mutually exclusive network_mode and networks". Dit raakt diff --git a/server-up/core/jobs.py b/server-up/core/jobs.py index 5fa8e3f..e6660a2 100644 --- a/server-up/core/jobs.py +++ b/server-up/core/jobs.py @@ -1,5 +1,6 @@ """Background job runner with log streaming.""" -import threading, time, uuid, queue +import json, os, threading, time, uuid, queue +from pathlib import Path _jobs: dict[str, dict] = {} _queues: dict[str, queue.Queue] = {} @@ -11,9 +12,82 @@ _TTL = 3600 # onbeperkt in het geheugen van het proces. _MAX_LINES = 2000 +# Waar afgeronde jobs blijven staan. Het geheugen was de enige plek: bij het +# bijwerken herstart de container middenin een taak, en dan was niet meer terug +# te zien of hij geslaagd was — het joblog gaf alleen nog "unknown". Ook na een +# uur (de TTL hierboven) verdween het bewijs van wat een nachtelijke backup had +# gedaan. +_dir = Path(os.environ.get("SU_JOBS", "/data/jobs")) +_BEWAAR = 20 + + +def _bestand(jid: str) -> Path: + return _dir / f"{jid}.json" + + +def _bewaar(jid: str): + """Schrijf de huidige stand van een job weg. Aanroepen onder `_lock`.""" + job = _jobs.get(jid) + if job is None: + return + try: + _dir.mkdir(parents=True, exist_ok=True) + tijdelijk = _bestand(jid).with_suffix(".tmp") + tijdelijk.write_text(json.dumps(job), encoding="utf-8") + # Vervangen in één stap: een half geschreven bestand zou bij de + # volgende start een onleesbare job opleveren. + tijdelijk.replace(_bestand(jid)) + except Exception: + # Een joblog is geen reden om de taak zelf te laten mislukken. + pass + + +def _opruimen_schijf(): + """Houd alleen de laatste `_BEWAAR` jobs op schijf.""" + try: + bestanden = sorted(_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, + reverse=True) + for oud in bestanden[_BEWAAR:]: + oud.unlink(missing_ok=True) + except Exception: + pass + + +def herstel(): + """Lees bewaarde jobs terug en markeer wat door een herstart is afgekapt. + + Zonder deze stap kijkt de interface na een herstart naar een job die niet + meer bestaat, blijft pollen op "unknown" en vertelt niemand wat er van de + taak geworden is. + """ + if not _dir.is_dir(): + return 0 + aantal = 0 + for pad in sorted(_dir.glob("*.json")): + try: + job = json.loads(pad.read_text(encoding="utf-8")) + except Exception: + continue + if not isinstance(job, dict) or not job.get("id"): + continue + if job.get("status") == "running": + job["status"] = "error" + job.setdefault("lines", []).append( + {"level": "warn", + "text": "Server Up is herstart terwijl deze taak liep; " + "wat erna gebeurde staat hier niet in."}) + with _lock: + _jobs[job["id"]] = job + aantal += 1 + _opruimen_schijf() + return aantal + def _cleanup(): - """Verwijder voltooide jobs ouder dan TTL uit geheugen.""" + """Verwijder voltooide jobs ouder dan TTL uit geheugen. + + Op schijf blijven ze staan; `stream` haalt ze daar weer vandaan. + """ cutoff = time.time() - _TTL with _lock: stale = [jid for jid, j in _jobs.items() @@ -65,6 +139,8 @@ def finish(jid: str, status="done"): with _lock: if jid in _jobs: _jobs[jid]["status"] = status + _bewaar(jid) + _opruimen_schijf() def done(q): @@ -75,7 +151,15 @@ def done(q): def stream(jid: str, offset=0) -> dict: job = _jobs.get(jid) if not job: - return {"lines": [], "status": "unknown"} + # Uit het geheugen verdwenen door de TTL of door een herstart, maar de + # laatste stand staat nog op schijf. + try: + bewaard = json.loads(_bestand(jid).read_text(encoding="utf-8")) + except Exception: + return {"lines": [], "status": "unknown"} + return {"lines": (bewaard.get("lines") or [])[offset:], + "status": bewaard.get("status", "unknown"), + "progress": bewaard.get("progress")} with _stream_locks.get(jid, threading.Lock()): q = _queues.get(jid) if q: @@ -94,6 +178,15 @@ def stream(jid: str, offset=0) -> dict: "text": f"… {dropped + job.get('dropped', 0)} eerdere regels weggelaten"}] + job["lines"][-_MAX_LINES:]) job["dropped"] = dropped + job.get("dropped", 0) + # Tussentijds wegschrijven, hooguit eens per paar seconden: valt de + # container om tijdens een lange taak, dan is tot dat punt terug te + # lezen wat er gebeurd is. Bij elke regel schrijven zou een `compose + # pull` in duizenden schrijfacties veranderen. + if job["status"] == "running" and \ + time.time() - job.get("bewaard_op", 0) > 3: + job["bewaard_op"] = time.time() + with _lock: + _bewaar(jid) return {"lines": job["lines"][offset:], "status": job["status"], "progress": job.get("progress")} diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 4088d48..e4f8e31 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -1895,6 +1895,30 @@ tailwind.config = {

+ + +
+
Onderdelen op het gedeelde netwerk
+

+
+ +
+