All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en een storing kwam alleen in het auditlog terecht. Meldingen (core/notify.py): - ntfy, webhook (Discord/Slack/Gotify) en e-mail, alle drie met de standaardbibliotheek. - Gebeurtenissen: mislukte backup, onleesbaar archief, vastgelopen taak, weinig schijfruimte, beschikbare update, gestopte container. - Eén bericht per ronde; bij schijfruimte en gestopte containers alleen bij de overgang, zodat je niet elk uur hetzelfde krijgt. - Testknop die eerst opslaat, zodat je test wat je net hebt ingevuld. - send() gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet alsnog laten omvallen. Schijfruimte (core/diskspace.py): - Controle vóór elke backup; past het niet, dan weigeren in plaats van halverwege afbreken. - DISK_MIN_FREE_GB blijft gereserveerd, DISK_WARN_PCT kleurt de balk rood. - Per filesystem één regel in de backuplijst. Backups controleren: - Elk nieuw archief wordt helemaal uitgelezen (tar-structuur plus gzip-checksum, die aan het eind staat). - Diepe variant pakt echt uit naar een tijdelijke map, met dezelfde beperkingen als een echt herstel. - Resultaat staat in de metadata en als schildje in de lijst. Backups de deur uit: - Downloadknop. - BACKUP_OFFSITE_DIR kopieert elke nieuwe backup naar een gemounte schijf, NFS- of SMB-share, via .part zodat een afgebroken kopie herkenbaar onaf is. - Ruimtecontrole op de bestemming, want een weggevallen mount laat vaak een lege map op de systeemschijf achter. NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only. Nieuw: docs/meldingen.md; docs/backups.md uitgebreid. Getest tegen een draaiende server met een echte ontvanger. 1064 tests groen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
"""Periodieke taken in één achtergrondthread.
|
|
|
|
Geen cron en geen extra dependency: één daemon-thread die elke paar minuten
|
|
kijkt of er iets te doen is. De laatste uitvoering staat in `config.json`, dus
|
|
een herstart laat de planning niet opnieuw beginnen — en een taak die door een
|
|
herstart gemist is, loopt bij de eerstvolgende tick alsnog.
|
|
|
|
Taken registreren zich met `register()`; `start()` zet de lus in gang.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from datetime import datetime
|
|
|
|
import core as cfg
|
|
|
|
TICK_SECONDS = 300 # elke 5 minuten kijken of er iets moet
|
|
_TAKEN: dict[str, dict] = {}
|
|
_LOCK = threading.Lock()
|
|
_gestart = False
|
|
|
|
|
|
def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""):
|
|
"""Registreer een periodieke taak.
|
|
|
|
`fn` retourneert **True** als het werk daadwerkelijk gedaan is, en False of
|
|
None als het moment nog niet daar was. Alleen bij True wordt de taak
|
|
afgestempeld. Dat onderscheid is essentieel voor taken die zelf nog een
|
|
tijdvenster bewaken: zou een overgeslagen taak ook afgestempeld worden, dan
|
|
verschuift het volgende moment naar precies dat verkeerde tijdstip en komt
|
|
de taak nooit meer aan de beurt.
|
|
|
|
`enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan
|
|
wordt de taak overgeslagen (maar blijft hij geregistreerd).
|
|
"""
|
|
with _LOCK:
|
|
_TAKEN[naam] = {"fn": fn, "interval": max(0.05, float(interval_uren)),
|
|
"enabled_key": enabled_key}
|
|
|
|
|
|
def _laatste() -> dict:
|
|
d = cfg.load().get("SCHEDULER_LAST") or {}
|
|
return d if isinstance(d, dict) else {}
|
|
|
|
|
|
def _markeer(naam: str):
|
|
d = dict(_laatste())
|
|
d[naam] = time.time()
|
|
cfg.patch({"SCHEDULER_LAST": d})
|
|
|
|
|
|
def due(naam: str, taak: dict, nu: float | None = None) -> bool:
|
|
"""Moet deze taak nu draaien?"""
|
|
nu = nu if nu is not None else time.time()
|
|
if taak.get("enabled_key") and not cfg.load().get(taak["enabled_key"]):
|
|
return False
|
|
laatste = _laatste().get(naam)
|
|
if not laatste:
|
|
return True
|
|
return (nu - float(laatste)) >= taak["interval"] * 3600
|
|
|
|
|
|
def run_due(log_fn=None) -> list[str]:
|
|
"""Draai alle taken die aan de beurt zijn. Retourneert de namen."""
|
|
gedraaid = []
|
|
with _LOCK:
|
|
items = list(_TAKEN.items())
|
|
for naam, taak in items:
|
|
try:
|
|
if not due(naam, taak):
|
|
continue
|
|
if log_fn:
|
|
log_fn(f"[scheduler] {naam}")
|
|
if taak["fn"]() is not True:
|
|
continue # overgeslagen: niet afstempelen
|
|
_markeer(naam)
|
|
gedraaid.append(naam)
|
|
except Exception as e:
|
|
# Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders
|
|
# blijft een structureel falende taak elke tick opnieuw proberen.
|
|
spoor = traceback.format_exc()
|
|
print(f" [scheduler] {naam} faalde:\n{spoor}")
|
|
_markeer(naam)
|
|
# Een taak die stilletjes faalt en netjes wordt afgestempeld, is
|
|
# precies het geval waarin je nooit merkt dat er niets meer gebeurt.
|
|
try:
|
|
from core import notify
|
|
notify.send("job_failed", f"Geplande taak '{naam}' mislukt",
|
|
f"{type(e).__name__}: {e}\n\n{spoor[-1500:]}")
|
|
except Exception:
|
|
pass
|
|
return gedraaid
|
|
|
|
|
|
def _lus():
|
|
# Even wachten tot de app volledig op is.
|
|
time.sleep(30)
|
|
while True:
|
|
try:
|
|
run_due()
|
|
except Exception:
|
|
print(f" [scheduler] tick faalde:\n{traceback.format_exc()}")
|
|
time.sleep(TICK_SECONDS)
|
|
|
|
|
|
def start():
|
|
"""Start de achtergrondlus (eenmalig)."""
|
|
global _gestart
|
|
if _gestart:
|
|
return
|
|
_gestart = True
|
|
threading.Thread(target=_lus, daemon=True, name="su-scheduler").start()
|
|
|
|
|
|
# ── Hulp voor dagelijkse taken op een vast tijdstip ──────────────────────────
|
|
|
|
def binnen_venster(uur: int, marge_minuten: int = 30, nu: datetime | None = None) -> bool:
|
|
"""True als de huidige tijd binnen `marge` minuten ná `uur`:00 ligt.
|
|
|
|
Voor 'draai dit rond 03:00'. De tick is 5 minuten, dus een venster van een
|
|
half uur vangt ruim af — combineer met de interval-check zodat een taak niet
|
|
meerdere keren in hetzelfde venster draait.
|
|
"""
|
|
nu = nu or datetime.now()
|
|
minuten_na = (nu.hour - uur) * 60 + nu.minute
|
|
return 0 <= minuten_na < marge_minuten
|