"""Page /cron — gestion des tâches planifiées Escada (push / sync auto).""" from __future__ import annotations import json import os import sys from datetime import datetime, timedelta from pathlib import Path import reflex as rx from sqlalchemy import select # Pour les imports src/* qui n'ont pas systématiquement le path setup _ROOT = Path(__file__).resolve().parent.parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from src.db import CronJob, get_session, init_db, Apprenti # noqa: E402 from src.notifier import test_telegram # noqa: E402 from src.logger import app_log # noqa: E402 from ..state import AuthState from ..sidebar import layout from ..components import empty_state _DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] _DAY_LABELS = { "MON": "Lun", "TUE": "Mar", "WED": "Mer", "THU": "Jeu", "FRI": "Ven", "SAT": "Sam", "SUN": "Dim", } # ── State ───────────────────────────────────────────────────────────────────── class CronState(AuthState): jobs: list[dict] = [] classes_avail: list[str] = [] # Édition editing_id: int = 0 # 0 = nouveau, sinon id du job en édition edit_open: bool = False f_name: str = "" f_enabled: bool = True f_schedule_kind: str = "daily" # "daily" | "weekly" | "interval" f_time_hh: str = "03" f_time_mm: str = "00" f_interval_min: str = "60" f_days: list[str] = [] # ["MON","WED",...] f_task_kind: str = "push_then_sync" f_sync_abs: bool = True f_sync_bn: bool = True f_sync_notes: bool = True f_sync_fiches: bool = False f_force_abs: bool = False f_classes_all: bool = True f_classes: list[str] = [] f_notify_on: str = "failure" f_notify_level: str = "normal" f_notify_chat_id: str = "" save_error: str = "" save_ok: bool = False tg_test_msg: str = "" tg_test_ok: bool = False # ── Helpers ─────────────────────────────────────────────────────────────── def _ensure_admin(self): return self.authenticated and self.role == "admin" def _job_to_dict(self, job: CronJob) -> dict: # Calcul d'une description lisible desc = self._human_schedule(job.schedule_kind, job.schedule_value) next_run = self._next_run_estimate(job) return { "id": job.id, "name": job.name, "enabled": job.enabled, "schedule_desc": desc, "task_kind": job.task_kind, "task_label": {"push": "Push", "sync": "Sync", "push_then_sync": "Push + Sync", "push_notices": "Push notices"}.get(job.task_kind, job.task_kind), "last_run_at": job.last_run_at.strftime("%d.%m.%Y %H:%M") if job.last_run_at else "", "last_status": job.last_status, "last_message": job.last_message[:120] if job.last_message else "", "last_log_path": job.last_log_path or "", "next_run": next_run, "notify_on": job.notify_on, } @staticmethod def _human_schedule(kind: str, value: str) -> str: if kind == "daily": return f"Tous les jours à {value}" if kind == "weekly": try: days_part, time_part = value.split(":", 1) labels = ", ".join(_DAY_LABELS.get(d.strip(), d.strip()) for d in days_part.split(",")) return f"{labels} à {time_part}" except ValueError: return value if kind == "interval": try: m = int(value) if m % 60 == 0: return f"Toutes les {m // 60} h" return f"Toutes les {m} min" except (TypeError, ValueError): return value return value @staticmethod def _next_run_estimate(job: CronJob) -> str: """Estime grossièrement la prochaine exécution (lisible).""" if not job.enabled: return "—" now = datetime.now() if job.schedule_kind == "interval": try: m = int(job.schedule_value) except (TypeError, ValueError): return "—" if job.last_run_at is None: return "Au prochain tick" nxt = job.last_run_at + timedelta(minutes=m) return nxt.strftime("%d.%m %H:%M") if job.schedule_kind == "daily": try: hh, mm = job.schedule_value.split(":") target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0) if (job.last_run_at and job.last_run_at.date() == now.date() and job.last_run_at >= target): target += timedelta(days=1) elif target < now: target += timedelta(days=1) return target.strftime("%d.%m %H:%M") except (ValueError, AttributeError): return "—" if job.schedule_kind == "weekly": return "Selon planning" return "—" # ── Load / refresh ──────────────────────────────────────────────────────── def load_data(self): if not self.authenticated: return rx.redirect("/login") if self.role != "admin": return rx.redirect("/accueil") # Garantit que la table existe try: init_db() except Exception: pass self._refresh() def _refresh(self): sess = get_session() try: jobs = sess.execute(select(CronJob).order_by(CronJob.id)).scalars().all() self.jobs = [self._job_to_dict(j) for j in jobs] # Liste des classes disponibles (réutilisé par le widget) classes = sess.execute( select(Apprenti.classe).distinct().order_by(Apprenti.classe) ).scalars().all() self.classes_avail = [c for c in classes if c and not c.startswith(("MP", "MI"))] finally: sess.close() # ── Form actions ────────────────────────────────────────────────────────── def open_new(self): self.editing_id = 0 self.edit_open = True self.f_name = "" self.f_enabled = True self.f_schedule_kind = "daily" self.f_time_hh = "03" self.f_time_mm = "00" self.f_interval_min = "60" self.f_days = ["MON", "TUE", "WED", "THU", "FRI"] self.f_task_kind = "push_then_sync" self.f_sync_abs = True self.f_sync_bn = True self.f_sync_notes = True self.f_sync_fiches = False self.f_force_abs = False self.f_classes_all = True self.f_classes = [] self.f_notify_on = "failure" self.f_notify_level = "normal" self.f_notify_chat_id = "" self.save_error = "" self.save_ok = False def open_edit(self, job_id: int): sess = get_session() try: job = sess.get(CronJob, job_id) if not job: return self.editing_id = job.id self.edit_open = True self.f_name = job.name self.f_enabled = job.enabled self.f_schedule_kind = job.schedule_kind if job.schedule_kind == "daily": hh, _, mm = (job.schedule_value or "03:00").partition(":") self.f_time_hh = hh.zfill(2) self.f_time_mm = mm.zfill(2) self.f_interval_min = "60" self.f_days = ["MON", "TUE", "WED", "THU", "FRI"] elif job.schedule_kind == "weekly": try: days_part, time_part = job.schedule_value.split(":", 1) hh, _, mm = time_part.partition(":") self.f_time_hh = hh.zfill(2) self.f_time_mm = mm.zfill(2) self.f_days = [d.strip() for d in days_part.split(",") if d.strip()] except ValueError: self.f_time_hh = "03" self.f_time_mm = "00" self.f_days = ["MON", "TUE", "WED", "THU", "FRI"] self.f_interval_min = "60" else: # interval self.f_interval_min = job.schedule_value or "60" self.f_time_hh = "03" self.f_time_mm = "00" self.f_days = ["MON", "TUE", "WED", "THU", "FRI"] self.f_task_kind = job.task_kind self.f_sync_abs = job.sync_abs self.f_sync_bn = job.sync_bn self.f_sync_notes = job.sync_notes self.f_sync_fiches = job.sync_fiches self.f_force_abs = job.force_abs classes_raw = (job.classes_json or "ALL").strip() if classes_raw == "ALL": self.f_classes_all = True self.f_classes = [] else: try: lst = json.loads(classes_raw) self.f_classes_all = False self.f_classes = [str(c) for c in (lst if isinstance(lst, list) else [])] except Exception: self.f_classes_all = True self.f_classes = [] self.f_notify_on = job.notify_on self.f_notify_level = getattr(job, "notify_level", "normal") or "normal" self.f_notify_chat_id = job.notify_chat_id or "" self.save_error = "" self.save_ok = False finally: sess.close() def close_edit(self): self.edit_open = False self.save_error = "" self.save_ok = False def set_f_name(self, v: str): self.f_name = v def set_f_enabled(self, v: bool): self.f_enabled = v def set_f_schedule_kind(self, v: str): self.f_schedule_kind = v def set_f_time_hh(self, v: str): v = "".join(ch for ch in v if ch.isdigit())[:2] self.f_time_hh = v def set_f_time_mm(self, v: str): v = "".join(ch for ch in v if ch.isdigit())[:2] self.f_time_mm = v def set_f_interval_min(self, v: str): v = "".join(ch for ch in v if ch.isdigit())[:5] self.f_interval_min = v def toggle_f_day(self, day: str): if day in self.f_days: self.f_days = [d for d in self.f_days if d != day] else: self.f_days = self.f_days + [day] def set_f_task_kind(self, v: str): self.f_task_kind = v def set_f_sync_abs(self, v: bool): self.f_sync_abs = v def set_f_sync_bn(self, v: bool): self.f_sync_bn = v def set_f_sync_notes(self, v: bool): self.f_sync_notes = v def set_f_sync_fiches(self, v: bool): self.f_sync_fiches = v def set_f_force_abs(self, v: bool): self.f_force_abs = v def set_f_classes_all(self, v: bool): self.f_classes_all = v def toggle_f_class(self, c: str): if c in self.f_classes: self.f_classes = [x for x in self.f_classes if x != c] else: self.f_classes = self.f_classes + [c] def set_f_notify_on(self, v: str): self.f_notify_on = v def set_f_notify_level(self, v: str): self.f_notify_level = v def set_f_notify_chat_id(self, v: str): self.f_notify_chat_id = v.strip() def save_job(self): self.save_error = "" self.save_ok = False if not self.f_name.strip(): self.save_error = "Le nom est obligatoire." return # Construire schedule_value selon kind if self.f_schedule_kind == "daily": try: hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0") if not (0 <= hh < 24 and 0 <= mm < 60): raise ValueError except ValueError: self.save_error = "Heure invalide." return schedule_value = f"{hh:02d}:{mm:02d}" elif self.f_schedule_kind == "weekly": if not self.f_days: self.save_error = "Sélectionne au moins un jour de la semaine." return try: hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0") if not (0 <= hh < 24 and 0 <= mm < 60): raise ValueError except ValueError: self.save_error = "Heure invalide." return ordered = [d for d in _DAY_NAMES if d in self.f_days] schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}" else: # interval try: m = int(self.f_interval_min or "0") if m < 1: raise ValueError except ValueError: self.save_error = "Intervalle invalide (minutes > 0)." return schedule_value = str(m) if self.f_classes_all: classes_json = "ALL" else: if not self.f_classes: self.save_error = "Sélectionne au moins une classe (ou coche \"Toutes les classes\")." return classes_json = json.dumps(self.f_classes) sess = get_session() user = self.username or "?" try: now = datetime.now() is_new = self.editing_id == 0 if is_new: job = CronJob( name=self.f_name.strip(), enabled=self.f_enabled, schedule_kind=self.f_schedule_kind, schedule_value=schedule_value, task_kind=self.f_task_kind, sync_abs=self.f_sync_abs, sync_bn=self.f_sync_bn, sync_notes=self.f_sync_notes, sync_fiches=self.f_sync_fiches, force_abs=self.f_force_abs, classes_json=classes_json, notify_on=self.f_notify_on, notify_level=self.f_notify_level, notify_chat_id=self.f_notify_chat_id, created_at=now, updated_at=now, ) sess.add(job) else: job = sess.get(CronJob, self.editing_id) if not job: self.save_error = "Job introuvable." return job.name = self.f_name.strip() job.enabled = self.f_enabled job.schedule_kind = self.f_schedule_kind job.schedule_value = schedule_value job.task_kind = self.f_task_kind job.sync_abs = self.f_sync_abs job.sync_bn = self.f_sync_bn job.sync_notes = self.f_sync_notes job.sync_fiches = self.f_sync_fiches job.force_abs = self.f_force_abs job.classes_json = classes_json job.notify_on = self.f_notify_on job.notify_level = self.f_notify_level job.notify_chat_id = self.f_notify_chat_id job.updated_at = now sess.commit() verb = "création" if is_new else "modification" app_log( f"[cron] {user} : {verb} tâche '{job.name}' (id={job.id}) — " f"{job.task_kind} / {schedule_value} / " f"{'activée' if job.enabled else 'désactivée'}" ) job_name = job.name self.save_ok = True self._refresh() self.edit_open = False return rx.toast.success( f"Tâche '{job_name}' " + ("créée" if is_new else "enregistrée") ) except Exception as e: sess.rollback() self.save_error = f"Erreur DB : {e}" return rx.toast.error(f"Erreur lors de l'enregistrement : {e}") finally: sess.close() def toggle_enabled(self, job_id: int): sess = get_session() user = self.username or "?" toast_msg = None try: job = sess.get(CronJob, job_id) if job: was_disabled = not job.enabled job.enabled = not job.enabled job.updated_at = datetime.now() # Si on passe de désactivé → activé, reset last_run_at pour qu'il # tourne au prochain tick (au lieu d'attendre last_run_at + interval). if was_disabled and job.enabled: job.last_run_at = None sess.commit() app_log( f"[cron] {user} : " f"{'activation' if job.enabled else 'désactivation'} " f"tâche '{job.name}' (id={job.id})" ) toast_msg = ( f"Tâche '{job.name}' " + ("activée" if job.enabled else "désactivée") ) self._refresh() finally: sess.close() if toast_msg: return rx.toast.success(toast_msg) def delete_job(self, job_id: int): sess = get_session() user = self.username or "?" toast_msg = None try: job = sess.get(CronJob, job_id) if job: job_name = job.name sess.delete(job) sess.commit() app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})") toast_msg = f"Tâche '{job_name}' supprimée" self._refresh() finally: sess.close() if toast_msg: return rx.toast.success(toast_msg) def test_telegram(self): ok, msg = test_telegram() self.tg_test_ok = ok self.tg_test_msg = msg # ── UI components ───────────────────────────────────────────────────────────── def _badge_status(status: rx.Var) -> rx.Component: return rx.match( status, ("ok", rx.badge("OK", color_scheme="green", variant="soft", size="1")), ("fail", rx.badge("Échec", color_scheme="red", variant="soft", size="1")), ("running", rx.badge("Running", color_scheme="orange", variant="soft", size="1")), rx.badge("—", color_scheme="gray", variant="soft", size="1"), ) def _job_row(job: rx.Var) -> rx.Component: return rx.box( rx.grid( # Nom + statut rx.vstack( rx.hstack( rx.text(job["name"], weight="bold", size="2"), rx.cond( job["enabled"], rx.badge("Actif", color_scheme="green", variant="soft", size="1"), rx.badge("Désactivé", color_scheme="gray", variant="soft", size="1"), ), spacing="2", align="center", wrap="wrap", ), rx.text(job["schedule_desc"], size="1", color="var(--gray-10)"), spacing="0", align="start", ), # Tâche rx.text(job["task_label"], size="2"), # Dernière exécution rx.vstack( rx.text( rx.cond(job["last_run_at"] != "", job["last_run_at"], "—"), size="1", ), _badge_status(job["last_status"]), spacing="1", align="start", ), # Prochaine exécution rx.text(job["next_run"], size="2", color="var(--gray-10)"), # Actions rx.hstack( rx.button( rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14), on_click=CronState.toggle_enabled(job["id"]), variant="ghost", size="1", color_scheme="gray", ), rx.button( rx.icon("pencil", size=14), on_click=CronState.open_edit(job["id"]), variant="ghost", size="1", color_scheme="gray", ), rx.alert_dialog.root( rx.alert_dialog.trigger( rx.button(rx.icon("trash-2", size=14), variant="ghost", size="1", color_scheme="red"), ), rx.alert_dialog.content( rx.alert_dialog.title("Supprimer ce job ?"), rx.alert_dialog.description( "Le job sera supprimé. Les fichiers de log conservés." ), rx.flex( rx.alert_dialog.cancel(rx.button("Annuler", variant="soft")), rx.alert_dialog.action( rx.button("Supprimer", color_scheme="red", on_click=CronState.delete_job(job["id"])), ), gap="2", justify="end", margin_top="1rem", ), ), ), spacing="1", align="center", ), columns="2.5fr 1fr 1.3fr 1.2fr auto", gap="0.75rem", align="center", width="100%", ), rx.cond( job["last_message"] != "", rx.text( "↳ ", job["last_message"], size="1", color="var(--gray-10)", margin_top="0.25rem", ), ), padding="0.75rem 1rem", background_color="white", border="1px solid var(--gray-5)", border_radius="6px", width="100%", ) def _form_schedule_picker() -> rx.Component: return rx.vstack( rx.text("Planification", size="2", font_weight="600"), rx.radio( ["daily", "weekly", "interval"], value=CronState.f_schedule_kind, on_change=CronState.set_f_schedule_kind, direction="row", ), rx.cond( CronState.f_schedule_kind == "interval", rx.hstack( rx.text("Toutes les", size="2"), rx.input( value=CronState.f_interval_min, on_change=CronState.set_f_interval_min, width="80px", ), rx.text("minutes", size="2"), spacing="2", align="center", ), rx.cond( CronState.f_schedule_kind == "weekly", rx.vstack( rx.flex( *[ rx.box( rx.text(_DAY_LABELS[d], size="1", weight="bold"), on_click=CronState.toggle_f_day(d), cursor="pointer", padding="0.35rem 0.7rem", border_radius="6px", border="2px solid", border_color=rx.cond( CronState.f_days.contains(d), "var(--red-9)", "var(--gray-6)", ), background_color=rx.cond( CronState.f_days.contains(d), "var(--red-9)", "transparent", ), color=rx.cond( CronState.f_days.contains(d), "white", "var(--gray-12)", ), ) for d in _DAY_NAMES ], gap="0.3rem", wrap="wrap", ), rx.hstack( rx.text("Heure :", size="2"), rx.input(value=CronState.f_time_hh, on_change=CronState.set_f_time_hh, width="60px"), rx.text(":", size="3"), rx.input(value=CronState.f_time_mm, on_change=CronState.set_f_time_mm, width="60px"), spacing="2", align="center", ), spacing="2", ), # daily rx.hstack( rx.text("Heure :", size="2"), rx.input(value=CronState.f_time_hh, on_change=CronState.set_f_time_hh, width="60px"), rx.text(":", size="3"), rx.input(value=CronState.f_time_mm, on_change=CronState.set_f_time_mm, width="60px"), spacing="2", align="center", ), ), ), spacing="2", width="100%", ) def _form_task_picker() -> rx.Component: return rx.vstack( rx.text("Tâche", size="2", font_weight="600"), rx.radio( ["push", "sync", "push_then_sync", "push_notices"], value=CronState.f_task_kind, on_change=CronState.set_f_task_kind, direction="column", ), rx.cond( (CronState.f_task_kind != "push") & (CronState.f_task_kind != "push_notices"), rx.vstack( rx.text("Données à synchroniser", size="2", font_weight="600", margin_top="0.5rem"), rx.flex( rx.hstack( rx.checkbox(checked=CronState.f_sync_abs, on_change=CronState.set_f_sync_abs, size="2"), rx.text("Absences", size="2"), spacing="2", align="center", ), rx.hstack( rx.checkbox(checked=CronState.f_sync_bn, on_change=CronState.set_f_sync_bn, size="2"), rx.text("BN + Matu", size="2"), spacing="2", align="center", ), rx.hstack( rx.checkbox(checked=CronState.f_sync_notes, on_change=CronState.set_f_sync_notes, size="2"), rx.text("Notes d'examen", size="2"), spacing="2", align="center", ), rx.hstack( rx.checkbox(checked=CronState.f_sync_fiches, on_change=CronState.set_f_sync_fiches, size="2"), rx.text("Fiches apprentis", size="2"), spacing="2", align="center", ), gap="0.5rem 1.25rem", flex_wrap="wrap", ), rx.hstack( rx.checkbox(checked=CronState.f_force_abs, on_change=CronState.set_f_force_abs, size="2"), rx.text("Forcer le retéléchargement des PDFs absences", size="2"), spacing="2", align="center", ), spacing="2", ), ), spacing="2", width="100%", ) def _form_classes_picker() -> rx.Component: return rx.vstack( rx.text("Classes", size="2", font_weight="600"), rx.hstack( rx.checkbox(checked=CronState.f_classes_all, on_change=CronState.set_f_classes_all, size="2"), rx.text("Toutes les classes", size="2"), spacing="2", align="center", ), rx.cond( ~CronState.f_classes_all, rx.flex( rx.foreach( CronState.classes_avail, lambda c: rx.box( rx.text(c, size="1"), on_click=CronState.toggle_f_class(c), cursor="pointer", padding="0.3rem 0.6rem", border_radius="9999px", border="1px solid", border_color=rx.cond( CronState.f_classes.contains(c), "var(--red-9)", "var(--gray-6)", ), background_color=rx.cond( CronState.f_classes.contains(c), "var(--red-9)", "transparent", ), color=rx.cond( CronState.f_classes.contains(c), "white", "var(--gray-12)", ), ), ), gap="0.3rem", wrap="wrap", ), ), spacing="2", width="100%", ) def _form_notify_picker() -> rx.Component: return rx.vstack( rx.text("Notifications Telegram", size="2", font_weight="600"), rx.text("Quand notifier", size="1", color="var(--gray-10)"), rx.radio( ["never", "failure", "success", "always"], value=CronState.f_notify_on, on_change=CronState.set_f_notify_on, direction="row", ), rx.text("Niveau de détail", size="1", color="var(--gray-10)", margin_top="0.25rem"), rx.radio( ["normal", "detailed"], value=CronState.f_notify_level, on_change=CronState.set_f_notify_level, direction="row", ), rx.text( rx.cond( CronState.f_notify_level == "detailed", "Détaillée : nom + statut + durée + classes importées + détail BN/notes/Matu + (nouvelles / modifiées / pending) absences", "Normal : nom + statut + durée uniquement", ), size="1", color="var(--gray-9)", ), rx.input( placeholder="Chat ID Telegram (vide = défaut configuré côté serveur)", value=CronState.f_notify_chat_id, on_change=CronState.set_f_notify_chat_id, width="100%", ), spacing="2", width="100%", ) def _edit_form() -> rx.Component: return rx.box( rx.vstack( rx.hstack( rx.text( rx.cond(CronState.editing_id == 0, "Nouveau job", "Modifier le job"), weight="bold", size="3", ), rx.spacer(), rx.button(rx.icon("x", size=14), on_click=CronState.close_edit, variant="ghost", color_scheme="gray", size="1"), width="100%", align="center", ), rx.divider(), rx.input( placeholder="Nom (ex: Sync nocturne)", value=CronState.f_name, on_change=CronState.set_f_name, width="100%", ), rx.hstack( rx.checkbox(checked=CronState.f_enabled, on_change=CronState.set_f_enabled, size="2"), rx.text("Activé", size="2"), spacing="2", align="center", ), rx.divider(), _form_schedule_picker(), rx.divider(), _form_task_picker(), rx.divider(), _form_classes_picker(), rx.divider(), _form_notify_picker(), rx.cond( CronState.save_error != "", rx.box( rx.text(CronState.save_error, color="red", size="2"), padding="0.5rem 1rem", background_color="#fff5f5", border="1px solid #ffcccc", border_radius="6px", width="100%", ), ), rx.hstack( rx.button("Enregistrer", on_click=CronState.save_job, color_scheme="indigo", size="2"), rx.button("Annuler", on_click=CronState.close_edit, variant="soft", color_scheme="gray", size="2"), spacing="2", ), spacing="3", width="100%", ), padding="1.25rem", background_color="var(--blue-2)", border_radius="8px", border="1px solid var(--blue-6)", width="100%", ) def _telegram_test_box() -> rx.Component: return rx.box( rx.hstack( rx.text("Notifications Telegram", weight="bold", size="2"), rx.spacer(), rx.button( rx.icon("send", size=14), "Envoyer un test", on_click=CronState.test_telegram, variant="outline", size="1", color_scheme="indigo", ), width="100%", align="center", ), rx.cond( CronState.tg_test_msg != "", rx.text( CronState.tg_test_msg, size="1", color=rx.cond(CronState.tg_test_ok, "var(--green-11)", "var(--red-11)"), margin_top="0.5rem", ), ), padding="0.75rem 1rem", background_color="var(--gray-2)", border="1px solid var(--gray-5)", border_radius="6px", width="100%", ) # ── Page ────────────────────────────────────────────────────────────────────── def cron_page() -> rx.Component: return layout( rx.vstack( rx.hstack( rx.heading("Tâches planifiées", size="7"), rx.spacer(), rx.button( rx.icon("plus", size=14), "Nouveau job", on_click=CronState.open_new, color_scheme="indigo", size="2", ), width="100%", align="center", ), rx.text( "Planification automatique des opérations Escada (push, sync). " "Le serveur lance ces jobs selon leur horaire (timezone Europe/Zurich).", size="2", color="var(--gray-10)", ), _telegram_test_box(), rx.cond( CronState.edit_open, _edit_form(), ), rx.cond( CronState.jobs.length() == 0, empty_state( icon="clock", title="Aucune tâche planifiée", description="Crée une tâche pour automatiser la synchronisation Escada (push, pull ou les deux).", ), rx.vstack( rx.foreach(CronState.jobs, _job_row), spacing="2", width="100%", ), ), spacing="4", width="100%", ) )