From ae1cbe27a478b1277f3b6407cfce9ef77aca0932 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Thu, 11 Jun 2026 22:50:58 +0200 Subject: [PATCH 1/7] feat: add core modules (config, state, diff, apply, health) --- apply.py | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++ config.py | 53 ++++++++++++++++++++++++++ diff.py | 65 ++++++++++++++++++++++++++++++++ health.py | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ state.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 apply.py create mode 100644 config.py create mode 100644 diff.py create mode 100644 health.py create mode 100644 state.py diff --git a/apply.py b/apply.py new file mode 100644 index 0000000..294b0cb --- /dev/null +++ b/apply.py @@ -0,0 +1,107 @@ +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +logger = logging.getLogger(__name__) + + +def _extract_uid(ical_data: bytes) -> str: + for line in ical_data.decode("utf-8", errors="replace").split("\r\n"): + if line.upper().startswith("UID:"): + return line[4:].strip() + return "" + + +def _find_event_by_uid(calendar: Any, uid: str) -> Any | None: + try: + for event in calendar.events(): + content = event.data.decode("utf-8", errors="replace") + for line in content.split("\r\n"): + if line.upper().startswith("UID:"): + if line[4:].strip() == uid: + return event + except Exception as exc: + logger.error("Error scanning events for UID %s: %s", uid, exc) + return None + + +def _add_event(calendar: Any, uid: str, ical_data: bytes) -> bool: + try: + calendar.add_event(ical_data) + return True + except Exception as exc: + logger.error("Failed to add event %s: %s", uid, exc) + return False + + +def _delete_event(calendar: Any, uid: str) -> bool: + try: + event = _find_event_by_uid(calendar, uid) + if event: + event.delete() + return True + logger.error("Event with UID %s not found", uid) + return False + except Exception as exc: + logger.error("Failed to delete event %s: %s", uid, exc) + return False + + +def _update_event(calendar: Any, uid: str, ical_data: bytes) -> bool: + if not _delete_event(calendar, uid): + return False + return _add_event(calendar, uid, ical_data) + + +def apply_adds(calendar: Any, events: dict[str, bytes], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_add_event, calendar, uid, data): uid + for uid, data in events.items() + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors + + +def apply_updates(calendar: Any, events: dict[str, bytes], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_update_event, calendar, uid, data): uid + for uid, data in events.items() + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors + + +def apply_deletes(calendar: Any, uids: list[str], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_delete_event, calendar, uid): uid + for uid in uids + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors diff --git a/config.py b/config.py new file mode 100644 index 0000000..8263c05 --- /dev/null +++ b/config.py @@ -0,0 +1,53 @@ +import os +from dataclasses import dataclass + + +HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Unraid-Sync/1.0"} + + +@dataclass +class Config: + ics_url: str + baikal_url: str + baikal_user: str + baikal_pass: str + sync_frequency: int + + +def validate() -> Config: + errors = [] + + ics_url = os.environ.get("ICS_URL") + if not ics_url: + errors.append("ICS_URL is required") + + baikal_url = os.environ.get("BAIKAL_URL") + if not baikal_url: + errors.append("BAIKAL_URL is required") + + baikal_user = os.environ.get("BAIKAL_USER") + if not baikal_user: + errors.append("BAIKAL_USER is required") + + baikal_pass = os.environ.get("BAIKAL_PASS") + if not baikal_pass: + errors.append("BAIKAL_PASS is required") + + sync_freq_raw = os.environ.get("SYNC_FREQUENCY", "5") + try: + sync_frequency = int(sync_freq_raw) + if sync_frequency <= 0: + raise ValueError + except ValueError: + errors.append("SYNC_FREQUENCY must be a positive integer") + + if errors: + raise ValueError("\n".join(errors)) + + return Config( + ics_url=ics_url, + baikal_url=baikal_url, + baikal_user=baikal_user, + baikal_pass=baikal_pass, + sync_frequency=sync_frequency, + ) diff --git a/diff.py b/diff.py new file mode 100644 index 0000000..d0d4051 --- /dev/null +++ b/diff.py @@ -0,0 +1,65 @@ +import hashlib +import logging +from icalendar import Calendar + +logger = logging.getLogger(__name__) + + +def parse_ics_events(ics_text: str) -> dict[str, str]: + cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text) + result = {} + for component in cal.walk(): + if component.name != "VEVENT": + continue + uid = component.get("UID") + if uid is None: + logger.warning("Skipping event with no UID") + continue + uid_str = str(uid) + try: + event_bytes = component.to_ical() + file_hash = hashlib.sha256(event_bytes).hexdigest() + result[uid_str] = file_hash + except Exception as e: + logger.warning("Failed to process event %s: %s", uid_str, e) + return result + + +def compute_diff(ics_uids: dict[str, str], known_uids: dict[str, str]) -> dict: + to_add = [] + to_update = [] + to_delete = [] + + for uid, file_hash in ics_uids.items(): + if uid not in known_uids: + to_add.append((uid, file_hash)) + elif known_uids[uid] != file_hash: + to_update.append((uid, file_hash)) + + for uid in known_uids: + if uid not in ics_uids: + to_delete.append(uid) + + return { + "to_add": to_add, + "to_update": to_update, + "to_delete": to_delete, + } + + +def parse_ics_events_with_data(ics_text: str) -> dict[str, bytes]: + cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text) + result = {} + for component in cal.walk(): + if component.name != "VEVENT": + continue + uid = component.get("UID") + if uid is None: + logger.warning("Skipping event with no UID") + continue + uid_str = str(uid) + try: + result[uid_str] = component.to_ical() + except Exception as e: + logger.warning("Failed to process event %s: %s", uid_str, e) + return result diff --git a/health.py b/health.py new file mode 100644 index 0000000..d20cebc --- /dev/null +++ b/health.py @@ -0,0 +1,111 @@ +import json +import threading +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from io import StringIO + + +class HealthServer: + def __init__(self, port: int = 8081): + self.port = port + self.lock = threading.Lock() + self.last_sync = None + self.last_sync_duration = 0.0 + self.last_sync_success = None + self.event_count = 0 + self.syncs_total = 0 + self.syncs_failed = 0 + self.server = None + self.thread = None + + def start(self) -> None: + handler = self._make_handler() + + self.server = HTTPServer(("0.0.0.0", self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def stop(self) -> None: + if self.server: + self.server.shutdown() + self.server = None + self.thread = None + + def update_status(self, last_sync: datetime, duration: float, success: bool, event_count: int) -> None: + with self.lock: + self.last_sync = last_sync + self.last_sync_duration = duration + self.last_sync_success = success + self.event_count = event_count + self.syncs_total += 1 + if not success: + self.syncs_failed += 1 + + def _make_handler(self): + server_self = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self._handle_health(server_self) + elif self.path == "/metrics": + self._handle_metrics(server_self) + else: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "not found"}).encode()) + + def _handle_health(self, srv): + with srv.lock: + status = "ok" + result = "none" + + if srv.last_sync_success is not None: + if srv.last_sync_success: + result = "success" + else: + result = "failure" + status = "error" + + payload = { + "status": status, + "last_sync": srv.last_sync.isoformat() if srv.last_sync else None, + "last_sync_duration_sec": srv.last_sync_duration, + "last_sync_result": result, + "event_count": srv.event_count, + "syncs_total": srv.syncs_total, + "syncs_failed": srv.syncs_failed, + } + + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def _handle_metrics(self, srv): + with srv.lock: + last_success = 1 if srv.last_sync_success else 0 + duration = srv.last_sync_duration + evt_count = srv.event_count + total = srv.syncs_total + failed = srv.syncs_failed + + buf = StringIO() + buf.write(f"baikal_sync_last_duration_seconds {duration}\n") + buf.write(f"baikal_sync_events_total {evt_count}\n") + buf.write(f"baikal_sync_total {total}\n") + buf.write(f"baikal_sync_failures_total {failed}\n") + buf.write(f"baikal_sync_last_success {last_success}\n") + + body = buf.getvalue().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + return Handler diff --git a/state.py b/state.py new file mode 100644 index 0000000..5f70e2a --- /dev/null +++ b/state.py @@ -0,0 +1,106 @@ +import sqlite3 +import threading +from datetime import datetime, timezone + + +class SyncState: + def __init__(self, db_path="./sync.db"): + self.db_path = db_path + self._lock = threading.Lock() + self._conn = sqlite3.connect(db_path, timeout=30) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS events ( + uid TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + synced_at TIMESTAMP NOT NULL + ) + """) + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS ics_cache ( + id INTEGER PRIMARY KEY CHECK(id=1), + ics_hash TEXT, + etag TEXT, + fetched_at TIMESTAMP + ) + """) + self._conn.commit() + return self + + def get_event_uids(self) -> set[str]: + with self._lock: + cur = self._conn.execute("SELECT uid FROM events") + return {row[0] for row in cur.fetchall()} + + def get_event_hash(self, uid: str) -> str | None: + with self._lock: + cur = self._conn.execute("SELECT content_hash FROM events WHERE uid = ?", (uid,)) + row = cur.fetchone() + return row[0] if row else None + + def upsert_event(self, uid: str, content_hash: str) -> None: + with self._lock: + self._conn.execute(""" + INSERT OR REPLACE INTO events (uid, content_hash, synced_at) + VALUES (?, ?, ?) + """, (uid, content_hash, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def delete_event(self, uid: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM events WHERE uid = ?", (uid,)) + self._conn.commit() + + def clear_events(self) -> int: + with self._lock: + cur = self._conn.execute("SELECT COUNT(*) FROM events") + count = cur.fetchone()[0] + self._conn.execute("DELETE FROM events") + self._conn.commit() + return count + + def get_ics_cache(self) -> tuple[str | None, str | None, str | None]: + with self._lock: + cur = self._conn.execute("SELECT ics_hash, etag, fetched_at FROM ics_cache WHERE id = 1") + row = cur.fetchone() + if row: + return (row[0], row[1], row[2]) + return (None, None, None) + + def set_ics_cache(self, ics_hash: str, etag: str | None) -> None: + with self._lock: + self._conn.execute(""" + INSERT INTO ics_cache (id, ics_hash, etag, fetched_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET ics_hash = ?, etag = ?, fetched_at = ? + """, (ics_hash, etag, datetime.now(timezone.utc).isoformat(), + ics_hash, etag, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def clear_ics_cache(self) -> None: + with self._lock: + self._conn.execute("DELETE FROM ics_cache") + self._conn.commit() + + def snapshot(self) -> dict: + with self._lock: + cur = self._conn.execute("SELECT uid, content_hash FROM events") + rows = cur.fetchall() + return { + "uids": [r[0] for r in rows], + "hashes": {r[0]: r[1] for r in rows} + } + + def restore_snapshot(self, data: dict) -> None: + with self._lock: + self._conn.execute("DELETE FROM events") + for uid, content_hash in data.get("hashes", {}).items(): + self._conn.execute(""" + INSERT INTO events (uid, content_hash, synced_at) + VALUES (?, ?, ?) + """, (uid, content_hash, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() From 64d078f45793bd896cb7592c13f3ed3634372568 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Thu, 11 Jun 2026 22:54:36 +0200 Subject: [PATCH 2/7] feat: rewrite sync orchestrator with incremental delta sync --- sync_calendar.py | 366 +++++++++++++++++++++++++++-------------------- 1 file changed, 212 insertions(+), 154 deletions(-) diff --git a/sync_calendar.py b/sync_calendar.py index 67807bb..b8c6e94 100644 --- a/sync_calendar.py +++ b/sync_calendar.py @@ -1,182 +1,240 @@ +import os +import signal +import time +import hashlib +import logging +import threading import requests import caldav -from caldav.elements import dav, cdav -from datetime import datetime -import os -import time -import concurrent.futures +from datetime import datetime, timezone -# --- CONFIGURACIÓN --- -# Default to 5 minutes -SYNC_FREQUENCY_MINUTES = int(os.getenv("SYNC_FREQUENCY", 5)) -SYNC_FREQUENCY_SECONDS = SYNC_FREQUENCY_MINUTES * 60 +from config import validate, HEADERS, Config +from state import SyncState +from diff import parse_ics_events, compute_diff, parse_ics_events_with_data +from apply import apply_adds, apply_updates, apply_deletes +from health import HealthServer -# Tu URL de Outlook -ICS_URL = os.getenv("ICS_URL") - -# Tu Baïkal -BAIKAL_URL = os.getenv("BAIKAL_URL") -BAIKAL_USER = os.getenv("BAIKAL_USER") -BAIKAL_PASS = os.getenv("BAIKAL_PASS") -CALENDAR_ID = os.getenv("CALENDAR_ID") - -# Headers para parecer un navegador real y evitar 'Connection Reset' -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Unraid-Sync/1.0" -} +logger = logging.getLogger(__name__) +shutdown_event = threading.Event() -def normalize_url(url): - """Normalize URLs to make matching robust against trailing slash differences.""" - return str(url).strip().rstrip("/") +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) -def find_calendar_by_url(calendars, target_url): - """Find a calendar whose URL matches the configured Baikal calendar URL.""" - normalized_target = normalize_url(target_url) +def find_calendar(client, config): + principal = client.principal() + calendars = principal.calendars() + calendar_id = os.environ.get("CALENDAR_ID") + + if calendar_id: + for cal in calendars: + if calendar_id in str(cal.url): + return cal + logger.error("Calendar with ID '%s' not found", calendar_id) + for c in calendars: + logger.error(" Available: %s", c.url) + return None + + target = config.baikal_url.rstrip("/") for cal in calendars: - if normalize_url(cal.url) == normalized_target: + if target in str(cal.url) or str(cal.url).rstrip("/") == target: return cal + + if calendars: + return calendars[0] + return None -def delete_event(event): - """Helper function to delete a single event.""" + +def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: + start_time = time.time() + logger.info("Starting sync cycle...") + try: - event.delete() - return True - except Exception as e: - print(f"!!! Error deleting event {event}: {e}") + r = requests.head(config.ics_url, headers=HEADERS, timeout=30, allow_redirects=True) + r.raise_for_status() + remote_etag = r.headers.get("ETag") + cached_hash, cached_etag, _ = state.get_ics_cache() + + if remote_etag and cached_etag == remote_etag: + logger.info("No changes detected (ETag match). Skipping sync.") + return True + + response = requests.get(config.ics_url, headers=HEADERS, timeout=30) + response.raise_for_status() + ics_text = response.text + ics_hash = hashlib.sha256(ics_text.encode("utf-8")).hexdigest() + + if cached_hash == ics_hash: + logger.info("No changes detected (hash match). Skipping sync.") + if remote_etag: + state.set_ics_cache(ics_hash, remote_etag) + return True + + state.set_ics_cache(ics_hash, remote_etag) + logger.info("ICS changed. Downloaded %d bytes, hash %s", len(ics_text), ics_hash[:12]) + + ics_uids = parse_ics_events(ics_text) + known_uids = {} + for uid in state.get_event_uids(): + h = state.get_event_hash(uid) + if h: + known_uids[uid] = h + + deltas = compute_diff(ics_uids, known_uids) + to_add = deltas["to_add"] + to_update = deltas["to_update"] + to_delete = deltas["to_delete"] + + if not to_add and not to_update and not to_delete: + logger.info("Calendar is already in sync.") + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + True, + len(ics_uids), + ) + return True + + logger.info( + "Delta: %d to add, %d to update, %d to delete", + len(to_add), + len(to_update), + len(to_delete), + ) + + client = caldav.DAVClient( + url=config.baikal_url, + username=config.baikal_user, + password=config.baikal_pass, + headers=HEADERS, + ssl_verify_cert=True, + ) + calendar = find_calendar(client, config) + if not calendar: + logger.error("Failed to find calendar") + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + False, + 0, + ) + return False + + snapshot = state.snapshot() + events_data = parse_ics_events_with_data(ics_text) + + add_events = {uid: events_data[uid] for uid, _ in to_add if uid in events_data} + update_events = {uid: events_data[uid] for uid, _ in to_update if uid in events_data} + delete_uids = to_delete + + try: + logger.info("Phase 1: Adding %d events...", len(add_events)) + if add_events: + s, e = apply_adds(calendar, add_events) + logger.info("Added %d/%d events (%d errors)", s, len(add_events), e) + + logger.info("Phase 2: Updating %d events...", len(update_events)) + if update_events: + s, e = apply_updates(calendar, update_events) + logger.info("Updated %d/%d events (%d errors)", s, len(update_events), e) + + logger.info("Phase 3: Deleting %d events...", len(delete_uids)) + if delete_uids: + s, e = apply_deletes(calendar, delete_uids) + logger.info("Deleted %d/%d events (%d errors)", s, len(delete_uids), e) + + for uid, h in ics_uids.items(): + state.upsert_event(uid, h) + + for uid in delete_uids: + state.delete_event(uid) + + total = len(ics_uids) + duration = time.time() - start_time + logger.info("Sync completed in %.1fs. Total events: %d", duration, total) + health.update_status( + datetime.now(timezone.utc), + duration, + True, + total, + ) + return True + + except Exception as exc: + logger.error("Sync failed: %s. Rolling back state.", exc) + state.restore_snapshot(snapshot) + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + False, + 0, + ) + return False + + except Exception as exc: + logger.error("Sync error: %s", exc) + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + False, + 0, + ) return False -def delete_all_events(calendar): - """ - Deletes all events in the calendar as fast as possible using threads. - """ - print("-> Buscando eventos para borrar...") + +def main(): + setup_logging() + logger.info("Starting Baikal Sync service...") + try: - events = calendar.events() - except Exception as e: - print(f"!!! Error al obtener eventos: {e}") - return + config = validate() + except ValueError as exc: + logger.error("Configuration error: %s", exc) + raise SystemExit(1) - total_events = len(events) - if total_events == 0: - print("-> El calendario ya está vacío.") - return + logger.info("Sync frequency: %d minutes", config.sync_frequency) + state = SyncState("./sync.db") + health = HealthServer(8081) + health.start() + logger.info("Health endpoint on :8081") - print(f"-> Borrando {total_events} eventos rápidamente...") - - # Usamos ThreadPoolExecutor para borrar en paralelo - deleted_count = 0 - with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: - results = list(executor.map(delete_event, events)) - deleted_count = results.count(True) + backoff = 0 - print(f"-> Limpieza completada. Borrados {deleted_count}/{total_events} eventos.") + def handle_signal(signum, frame): + logger.info("Received signal %s. Shutting down...", signum) + shutdown_event.set() -def sync(): - if not all([ICS_URL, BAIKAL_URL, BAIKAL_USER, BAIKAL_PASS]): - print(f"[{datetime.now()}] !!! Error: Faltan variables de entorno. Asegúrate de configurar ICS_URL, BAIKAL_URL, BAIKAL_USER y BAIKAL_PASS.") - return + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) - print(f"[{datetime.now()}] Iniciando sincronización...") - - # 1. Descargar ICS de Outlook - print("-> Descargando calendario de Outlook...") - try: - response = requests.get(ICS_URL, headers=HEADERS, timeout=30) - response.raise_for_status() - ics_data = response.text - print(f"-> Descarga exitosa ({len(ics_data)} bytes).") - except Exception as e: - print(f"!!! Error descargando Outlook: {e}") - return + while not shutdown_event.is_set(): + success = sync_once(state, health, config) - # 2. Conectar a Baïkal - print("-> Conectando a Baïkal...") - try: - client = caldav.DAVClient( - url=BAIKAL_URL, - username=BAIKAL_USER, - password=BAIKAL_PASS, - headers=HEADERS, # Clave para evitar el bloqueo - ssl_verify_cert=True # Cambiar a False si tienes problemas de certificado SSL auto-firmado - ) - principal = client.principal() - calendars = principal.calendars() - - # Buscar el calendario correcto por ID si se proporciona - calendar = None - if CALENDAR_ID: - print(f"-> Buscando calendario con ID: {CALENDAR_ID}") - for cal in calendars: - # Comprobamos si el ID está en la URL del calendario - if CALENDAR_ID in str(cal.url): - calendar = cal - break - - if not calendar: - print(f"!!! Error: No se encontró ningún calendario con el ID '{CALENDAR_ID}'. Calendarios disponibles:") - for c in calendars: - print(f" - {c.url}") - return + if success: + backoff = 0 + sleep_time = config.sync_frequency * 60 else: - # Si no hay CALENDAR_ID, seleccionar por BAIKAL_URL - if not calendars: - print("!!! No se encontró ningún calendario en esa URL.") - return + backoff = max(1, min(backoff * 2 if backoff > 0 else 1, 30)) + sleep_time = backoff * 60 + logger.info("Sync failed. Backing off %d minutes...", backoff) - calendar = find_calendar_by_url(calendars, BAIKAL_URL) - if not calendar: - print("!!! Error: No se encontró un calendario que coincida con BAIKAL_URL.") - print(f" BAIKAL_URL configurado: {BAIKAL_URL}") - print(" Calendarios disponibles:") - for c in calendars: - print(f" - {c.url}") - return + logger.info("Next sync in %d seconds...", sleep_time) + shutdown_event.wait(sleep_time) - print(f"-> Calendario seleccionado: {calendar}") - print(f"-> URL del calendario seleccionado: {calendar.url}") + state.close() + health.stop() + logger.info("Shutdown complete.") - # 3. Borrar eventos antiguos (NUEVO) - delete_all_events(calendar) - - # 4. Importar eventos - print("-> Procesando archivo ICS...") - from icalendar import Calendar - cal = Calendar.from_ical(ics_data) - - events = cal.walk('vevent') - total_events = len(events) - print(f"-> Encontrados {total_events} eventos para importar.") - - success_count = 0 - error_count = 0 - - for i, component in enumerate(events, 1): - try: - # Intentamos pasar el string decodificado - calendar.add_event(component.to_ical().decode('utf-8')) - success_count += 1 - except Exception as ev_err: - error_count += 1 - # Solo imprimimos los primeros 5 errores para no saturar - if error_count <= 5: - summary = component.get('summary', 'sin titulo') - print(f"!!! Error ({i}/{total_events}) '{summary}': {ev_err}") - - # Print progress every 50 events - if i % 50 == 0: - print(f" Procesados {i}/{total_events} (Exitos: {success_count}, Errores: {error_count})") - - print(f"-> ¡Sincronización finalizada! Éxitos: {success_count}, Errores: {error_count}") - - except Exception as e: - print(f"!!! Error en Baïkal: {e}") if __name__ == "__main__": - print(f"Iniciando servicio de sincronización. Frecuencia: {SYNC_FREQUENCY_MINUTES} minutos ({SYNC_FREQUENCY_SECONDS} segundos).") - while True: - sync() - print(f"[{datetime.now()}] Durmiendo {SYNC_FREQUENCY_MINUTES} minutos...") - time.sleep(SYNC_FREQUENCY_SECONDS) + main() From 0f390ff1e191ef39d2c045a05934164f4d9bdae7 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Thu, 11 Jun 2026 23:00:34 +0200 Subject: [PATCH 3/7] feat: add tests, update configs, fix state.py return, update gitignore --- .env.example | 6 ++++ .gitignore | 10 ++++++ Dockerfile | 7 +++-- docker-compose.yml | 12 +++++--- main.py | 4 +-- pyproject.toml | 12 ++++++-- requirements.txt | 7 +++-- state.py | 1 - tests/conftest.py | 68 +++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 49 +++++++++++++++++++++++++++++ tests/test_diff.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_state.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_diff.py create mode 100644 tests/test_state.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c97309c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +ICS_URL=https://outlook.office365.com/owa/calendar/your-calendar.ics +BAIKAL_URL=https://your-baikal.com/dav.php/calendars/USER/calendar-id/ +BAIKAL_USER=your-username +BAIKAL_PASS=your-password +SYNC_FREQUENCY=5 +CALENDAR_ID= diff --git a/.gitignore b/.gitignore index 110b0a6..3891c11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,13 @@ wheels/ # Virtual environments .venv .env + +# Sync state database +*.db + +# Playwright MCP artifacts +.playwright-mcp/ +*.png + +# Test artifacts +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 12f6bc2..db4d074 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY sync_calendar.py . +COPY . . -CMD ["python", "-u", "sync_calendar.py"] +HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" || exit 1 + +CMD ["python", "-u", "main.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 60e3517..659d65a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,11 @@ services: - BAIKAL_URL=${BAIKAL_URL} - BAIKAL_USER=${BAIKAL_USER} - BAIKAL_PASS=${BAIKAL_PASS} - - SYNC_FREQUENCY=5 # Minutes - # If your Baikal is on the same host, you might need network_mode: "host" - # or ensure they share a network. - # network_mode: "host" + - SYNC_FREQUENCY=${SYNC_FREQUENCY:-5} + - CALENDAR_ID=${CALENDAR_ID:-} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 5s diff --git a/main.py b/main.py index 9d3c4bd..23f4e99 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,4 @@ -def main(): - print("Hello from ics-to-caldav!") - +from sync_calendar import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 4220eb6..81c71b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ics-to-caldav" -version = "0.1.0" -description = "Add your description here" +version = "1.0.0" +description = "Incremental CalDAV sync from Outlook ICS to Baikal" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -9,3 +9,11 @@ dependencies = [ "icalendar>=6.3.2", "requests>=2.32.5", ] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index eeb459b..9536ef1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -caldav>=1.0.0 -icalendar>=5.0.0 -requests>=2.20.0 +caldav>=2.2.3 +icalendar>=6.3.2 +requests>=2.32.5 +pytest>=8.0 diff --git a/state.py b/state.py index 5f70e2a..d9954ae 100644 --- a/state.py +++ b/state.py @@ -25,7 +25,6 @@ class SyncState: ) """) self._conn.commit() - return self def get_event_uids(self) -> set[str]: with self._lock: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b20f0a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +import os +import sys +import pytest +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +@pytest.fixture +def ics_sample(): + """Return a sample ICS string with 2 events.""" + return """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test +BEGIN:VEVENT +UID:evt-001@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +SUMMARY:Event One +END:VEVENT +BEGIN:VEVENT +UID:evt-002@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240102T100000Z +DTEND:20240102T110000Z +SUMMARY:Event Two +END:VEVENT +END:VCALENDAR""" + +@pytest.fixture +def ics_sample_modified(): + """Return ICS with one modified event and one new event.""" + return """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test +BEGIN:VEVENT +UID:evt-001@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +DTEND:20240101T120000Z +SUMMARY:Event One Modified +END:VEVENT +BEGIN:VEVENT +UID:evt-003@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240103T100000Z +DTEND:20240103T110000Z +SUMMARY:Event Three +END:VEVENT +END:VCALENDAR""" + +@pytest.fixture +def tmp_db(): + """Return a temp database path.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + if os.path.exists(path): + os.unlink(path) + +@pytest.fixture(autouse=True) +def env_vars(monkeypatch): + """Set up required env vars for config tests.""" + monkeypatch.setenv("ICS_URL", "https://example.com/cal.ics") + monkeypatch.setenv("BAIKAL_URL", "https://baikal.com/dav.php/calendars/user/cal/") + monkeypatch.setenv("BAIKAL_USER", "user") + monkeypatch.setenv("BAIKAL_PASS", "pass") + monkeypatch.setenv("SYNC_FREQUENCY", "5") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bb315aa --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,49 @@ +import os +import pytest +from config import validate, HEADERS, Config + +class TestValidate: + def test_valid_config(self, env_vars): + cfg = validate() + assert isinstance(cfg, Config) + assert cfg.ics_url == "https://example.com/cal.ics" + assert cfg.baikal_url == "https://baikal.com/dav.php/calendars/user/cal/" + assert cfg.baikal_user == "user" + assert cfg.baikal_pass == "pass" + assert cfg.sync_frequency == 5 + + def test_default_frequency(self, monkeypatch, env_vars): + monkeypatch.delenv("SYNC_FREQUENCY", raising=False) + cfg = validate() + assert cfg.sync_frequency == 5 + + def test_missing_ics_url(self, monkeypatch, env_vars): + monkeypatch.delenv("ICS_URL", raising=False) + with pytest.raises(ValueError, match="ICS_URL"): + validate() + + def test_missing_all_required(self, monkeypatch, env_vars): + for var in ["ICS_URL", "BAIKAL_URL", "BAIKAL_USER", "BAIKAL_PASS"]: + monkeypatch.delenv(var, raising=False) + with pytest.raises(ValueError): + validate() + + def test_invalid_frequency(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "-1") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + + def test_frequency_zero(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "0") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + + def test_frequency_string(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "abc") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + +class TestHeaders: + def test_headers_exist(self): + assert "User-Agent" in HEADERS + assert "Unraid-Sync" in HEADERS["User-Agent"] diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..913a6e2 --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,73 @@ +from diff import parse_ics_events, compute_diff, parse_ics_events_with_data + +class TestParseIcsEvents: + def test_parse_basic(self, ics_sample): + result = parse_ics_events(ics_sample) + assert "evt-001@test.com" in result + assert "evt-002@test.com" in result + assert len(result) == 2 + + def test_parse_no_uid(self): + ics = """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +SUMMARY:No UID +END:VEVENT +END:VCALENDAR""" + result = parse_ics_events(ics) + assert len(result) == 0 + + def test_hashes_are_sha256(self, ics_sample): + result = parse_ics_events(ics_sample) + for uid, h in result.items(): + assert len(h) == 64 + int(h, 16) + + def test_same_event_same_hash(self, ics_sample): + r1 = parse_ics_events(ics_sample) + r2 = parse_ics_events(ics_sample) + for uid in r1: + assert r1[uid] == r2[uid] + + def test_modified_event_different_hash(self, ics_sample, ics_sample_modified): + r1 = parse_ics_events(ics_sample) + r2 = parse_ics_events(ics_sample_modified) + assert r1["evt-001@test.com"] != r2["evt-001@test.com"] + +class TestComputeDiff: + def test_no_changes(self, ics_sample): + ics_uids = parse_ics_events(ics_sample) + deltas = compute_diff(ics_uids, ics_uids) + assert len(deltas["to_add"]) == 0 + assert len(deltas["to_update"]) == 0 + assert len(deltas["to_delete"]) == 0 + + def test_add_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + assert ("evt-003@test.com", modified["evt-003@test.com"]) in deltas["to_add"] + + def test_delete_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + assert "evt-002@test.com" in deltas["to_delete"] + + def test_update_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + uid, new_hash = deltas["to_update"][0] + assert uid == "evt-001@test.com" + assert new_hash == modified["evt-001@test.com"] + +class TestParseIcsEventsWithData: + def test_returns_bytes(self, ics_sample): + result = parse_ics_events_with_data(ics_sample) + assert len(result) == 2 + for uid, data in result.items(): + assert isinstance(data, bytes) + assert b"VEVENT" in data diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..4cf0bf5 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,71 @@ +from state import SyncState + +class TestSyncState: + def test_create_tables(self, tmp_db): + state = SyncState(tmp_db) + assert True + state.close() + + def test_upsert_and_get(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "hash-abc") + assert state.get_event_hash("uid-1") == "hash-abc" + state.close() + + def test_get_uids(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + uids = state.get_event_uids() + assert "uid-1" in uids + assert "uid-2" in uids + assert len(uids) == 2 + state.close() + + def test_delete_event(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.delete_event("uid-1") + assert state.get_event_hash("uid-1") is None + state.close() + + def test_clear_events(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + count = state.clear_events() + assert count == 2 + assert len(state.get_event_uids()) == 0 + state.close() + + def test_ics_cache(self, tmp_db): + state = SyncState(tmp_db) + state.set_ics_cache("hash-xyz", "etag-123") + h, e, _ = state.get_ics_cache() + assert h == "hash-xyz" + assert e == "etag-123" + state.clear_ics_cache() + h, e, _ = state.get_ics_cache() + assert h is None + assert e is None + state.close() + + def test_snapshot_and_restore(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + snap = state.snapshot() + assert "uid-1" in snap["uids"] + state.clear_events() + assert len(state.get_event_uids()) == 0 + state.restore_snapshot(snap) + assert len(state.get_event_uids()) == 2 + assert state.get_event_hash("uid-1") == "h1" + state.close() + + def test_empty_state(self, tmp_db): + state = SyncState(tmp_db) + assert len(state.get_event_uids()) == 0 + h, e, _ = state.get_ics_cache() + assert h is None + state.close() From d628f03a827f377477d4b04f82a686365711a96c Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Thu, 11 Jun 2026 23:06:47 +0200 Subject: [PATCH 4/7] fix: stable event hashing, HEAD fallback, caldav str/bytes compat --- apply.py | 13 ++++++++++--- diff.py | 17 ++++++++++++++--- sync_calendar.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apply.py b/apply.py index 294b0cb..8cab090 100644 --- a/apply.py +++ b/apply.py @@ -5,8 +5,11 @@ from typing import Any logger = logging.getLogger(__name__) -def _extract_uid(ical_data: bytes) -> str: - for line in ical_data.decode("utf-8", errors="replace").split("\r\n"): +def _extract_uid(ical_data) -> str: + raw = ical_data + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + for line in str(raw).split("\r\n"): if line.upper().startswith("UID:"): return line[4:].strip() return "" @@ -15,7 +18,11 @@ def _extract_uid(ical_data: bytes) -> str: def _find_event_by_uid(calendar: Any, uid: str) -> Any | None: try: for event in calendar.events(): - content = event.data.decode("utf-8", errors="replace") + raw = event.data + if isinstance(raw, bytes): + content = raw.decode("utf-8", errors="replace") + else: + content = str(raw) for line in content.split("\r\n"): if line.upper().startswith("UID:"): if line[4:].strip() == uid: diff --git a/diff.py b/diff.py index d0d4051..7f18706 100644 --- a/diff.py +++ b/diff.py @@ -5,6 +5,19 @@ from icalendar import Calendar logger = logging.getLogger(__name__) +def _stable_event_key(component) -> str: + event_bytes = component.to_ical() + text = event_bytes.decode("utf-8", errors="replace") + stable_lines = [] + for line in text.split("\r\n"): + key = line.split(":")[0].upper() if ":" in line else "" + if key in ("DTSTAMP", "LAST-MODIFIED"): + continue + stable_lines.append(line) + stable_text = "\r\n".join(stable_lines) + return hashlib.sha256(stable_text.encode("utf-8")).hexdigest() + + def parse_ics_events(ics_text: str) -> dict[str, str]: cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text) result = {} @@ -17,9 +30,7 @@ def parse_ics_events(ics_text: str) -> dict[str, str]: continue uid_str = str(uid) try: - event_bytes = component.to_ical() - file_hash = hashlib.sha256(event_bytes).hexdigest() - result[uid_str] = file_hash + result[uid_str] = _stable_event_key(component) except Exception as e: logger.warning("Failed to process event %s: %s", uid_str, e) return result diff --git a/sync_calendar.py b/sync_calendar.py index b8c6e94..b265ab0 100644 --- a/sync_calendar.py +++ b/sync_calendar.py @@ -56,9 +56,14 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: logger.info("Starting sync cycle...") try: - r = requests.head(config.ics_url, headers=HEADERS, timeout=30, allow_redirects=True) - r.raise_for_status() - remote_etag = r.headers.get("ETag") + remote_etag = None + try: + r = requests.head(config.ics_url, headers=HEADERS, timeout=15, allow_redirects=True) + if r.status_code < 400: + remote_etag = r.headers.get("ETag") + except Exception: + pass + cached_hash, cached_etag, _ = state.get_ics_cache() if remote_etag and cached_etag == remote_etag: @@ -91,6 +96,8 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: to_update = deltas["to_update"] to_delete = deltas["to_delete"] + is_first_run = not state.get_event_uids() and not cached_hash + if not to_add and not to_update and not to_delete: logger.info("Calendar is already in sync.") duration = time.time() - start_time @@ -100,6 +107,24 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: True, len(ics_uids), ) + for uid, h in ics_uids.items(): + state.upsert_event(uid, h) + return True + + if is_first_run and not to_delete: + logger.info( + "First run detected: %d events in ICS. Registering state without re-adding to calendar.", + len(to_add), + ) + for uid, h in ics_uids.items(): + state.upsert_event(uid, h) + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + True, + len(ics_uids), + ) return True logger.info( From 20247c92c29a3b5d5b4fa7899a000aea0f472362 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Thu, 11 Jun 2026 23:07:10 +0200 Subject: [PATCH 5/7] docs: update README for v1.0.0 incremental sync --- README.md | 56 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 373ae07..d46d3c6 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,75 @@ # Baïkal Calendar Sync -A Dockerized Python service that periodically synchronizes an external Outlook ICS calendar to a local Baïkal CalDAV server. +An incremental CalDAV sync service that keeps your Baïkal calendar in sync with an Outlook ICS feed — with zero downtime and near-instant sync times. ## Features -- **Automatic Sync**: Runs periodically (default: every 5 minutes). -- **Environment Configurable**: All credentials and URLs are set via environment variables. -- **Docker Ready**: Includes `Dockerfile` and `docker-compose.yml` for easy deployment. + +- **Incremental Delta Sync**: Only adds, updates, or deletes events that actually changed +- **Zero Downtime**: Adds/updates events before deleting removed ones — calendar never goes empty +- **Smart Change Detection**: ETag check first, SHA-256 fallback. Skips sync when nothing changed +- **Stable Event Hashing**: Ignores volatile fields (DTSTAMP, LAST-MODIFIED) to avoid false updates +- **Automatic Backoff**: Exponential retry on failures (1m, 2m, 4m... max 30m) +- **Health Endpoint**: Built-in `/health` and `/metrics` (Prometheus-compatible) on port 8081 +- **Graceful Shutdown**: SIGTERM/SIGINT handlers finish current sync before exiting +- **Docker Ready**: Includes `Dockerfile`, `docker-compose.yml`, and HEALTHCHECK ## Prerequisites -- Docker & Docker Compose installed. -- An existing Baïkal instance. -- An Outlook ICS link. + +- Docker & Docker Compose installed +- An existing Baïkal instance +- An Outlook ICS link ## Configuration (Environment Variables) | Variable | Description | Default | | :--- | :--- | :--- | | `ICS_URL` | The public ICS URL of your Outlook calendar. | **Required** | -| `BAIKAL_URL` | The URL to your Baïkal calendar (e.g., `http://baikal/dav.php/calendars/user/id/`). | **Required** | +| `BAIKAL_URL` | The URL to your Baïkal calendar. | **Required** | | `BAIKAL_USER` | Your Baïkal username. | **Required** | | `BAIKAL_PASS` | Your Baïkal password. | **Required** | | `SYNC_FREQUENCY` | How often to sync **in minutes**. | `5` | +| `CALENDAR_ID` | Optional calendar ID to match against. | — | -## Quick Start with Docker Compose +## Quick Start -1. **Clone or download** this repository. -2. **Create a `.env` file** (optional but recommended) or export variables: +1. **Clone or download** this repository. +2. **Create a `.env` file** (optional but recommended): ```bash - ICS_URL="https://outlook.office365.com/..." - BAIKAL_URL="http://localhost:8080/dav.php/calendars/Lago/default/" - BAIKAL_USER="Lago" - BAIKAL_PASS="secret" + ICS_URL="https://outlook.office365.com/owa/calendar/your-calendar.ics" + BAIKAL_URL="https://your-baikal.com/dav.php/calendars/user/calendar-id/" + BAIKAL_USER="your-username" + BAIKAL_PASS="your-password" SYNC_FREQUENCY=5 ``` -3. **Run with Docker Compose**: +3. **Run with Docker Compose**: ```bash docker compose up -d ``` - *Note: This will verify the image usage. If you need to rebuild locally, run `docker compose up -d --build`.* -4. **Check Logs**: +4. **Check Logs**: ```bash docker compose logs -f baikal-sync ``` -## Building & Publishing the Image +5. **Check Health**: -1. **Build**: + ```bash + curl http://localhost:8081/health + curl http://localhost:8081/metrics + ``` + +## Building & Publishing + +1. **Build**: ```bash docker build -t lagortinez/baikal-sync:latest . ``` -2. **Push** (requires `docker login`): +2. **Push**: ```bash docker push lagortinez/baikal-sync:latest ``` From 7a388653edb8ecf538d5a848a372d70a905a5546 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Fri, 12 Jun 2026 08:11:24 +0200 Subject: [PATCH 6/7] feat: dashboard UI with real-time stats, light/dark mode, session metrics --- Dockerfile | 2 + dashboard.py | 146 +++++++++ docker-compose.yml | 3 + health.py | 73 +++++ static/dashboard.html | 731 ++++++++++++++++++++++++++++++++++++++++++ sync_calendar.py | 141 +++++--- 6 files changed, 1050 insertions(+), 46 deletions(-) create mode 100644 dashboard.py create mode 100644 static/dashboard.html diff --git a/Dockerfile b/Dockerfile index db4d074..961f932 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +EXPOSE 8081 8082 + HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" || exit 1 diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..11520f0 --- /dev/null +++ b/dashboard.py @@ -0,0 +1,146 @@ +import json +import os +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +from health import HealthServer, SyncSession + + +class DashboardServer: + def __init__(self, port: int = 8082, health: HealthServer = None, session: SyncSession = None): + self.port = port + self.health = health + self.session = session + self.server = None + self.thread = None + self._lock = threading.Lock() + self._syncing = False + self._next_sync_in = 0 + self._event_count = 0 + self._backoff_min = 0 + self._config = {} + self._base_dir = os.path.dirname(os.path.abspath(__file__)) + + def start(self): + handler = self._make_handler() + self.server = HTTPServer(("0.0.0.0", self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def stop(self): + if self.server: + self.server.shutdown() + self.server = None + self.thread = None + + def update_config(self, config_dict: dict): + with self._lock: + self._config = dict(config_dict) + + def set_syncing(self, syncing: bool): + with self._lock: + self._syncing = syncing + + def set_next_sync_in(self, seconds: int): + with self._lock: + self._next_sync_in = seconds + + def set_event_count(self, n: int): + with self._lock: + self._event_count = n + + def set_backoff_min(self, n: int): + with self._lock: + self._backoff_min = n + + def _get_status(self): + with self._lock: + syncing = self._syncing + next_sync_in = self._next_sync_in + event_count = self._event_count + backoff_min = self._backoff_min + config = dict(self._config) + + last_sync = None + duration = 0.0 + last_success = None + if self.health: + with self.health.lock: + last_sync = self.health.last_sync + duration = self.health.last_sync_duration + last_success = self.health.last_sync_success + + status = "idle" + if syncing: + status = "syncing" + elif last_success is False: + status = "error" + elif backoff_min > 0: + status = "backoff" + + session_data = {} + history = [] + if self.session: + session_data = self.session.get_status() + history = session_data.get("history", []) + + ics_latency = 0.0 + if self.session and self.session.non_skip_count > 0: + ics_latency = round(self.session.total_latency_ms / self.session.non_skip_count) + + return { + "status": status, + "last_sync": last_sync.isoformat() if last_sync else None, + "duration": duration, + "ics_latency_ms": ics_latency, + "event_count": event_count, + "next_sync_in": next_sync_in, + "session": session_data, + "history": history, + "config": config, + } + + def _make_handler(self): + server_self = self + base_dir = server_self._base_dir + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/api/status": + self._handle_api(server_self) + elif self.path == "/": + self._handle_dashboard(base_dir) + else: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "not found"}).encode()) + + def _handle_api(self, srv): + data = srv._get_status() + body = json.dumps(data).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def _handle_dashboard(self, base): + html_path = os.path.join(base, "static", "dashboard.html") + try: + with open(html_path, "r", encoding="utf-8") as f: + content = f.read() + body = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(body) + except FileNotFoundError: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "dashboard not found"}).encode()) + + def log_message(self, format, *args): + pass + + return Handler diff --git a/docker-compose.yml b/docker-compose.yml index 659d65a..0477a35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,9 @@ services: image: lagortinez/baikal-sync:latest container_name: baikal-sync restart: always + ports: + - "8081:8081" + - "8082:8082" environment: - ICS_URL=${ICS_URL} - BAIKAL_URL=${BAIKAL_URL} diff --git a/health.py b/health.py index d20cebc..b220e91 100644 --- a/health.py +++ b/health.py @@ -1,10 +1,83 @@ import json import threading +import time +from collections import deque from datetime import datetime, timezone from http.server import HTTPServer, BaseHTTPRequestHandler from io import StringIO +class SyncSession: + def __init__(self): + self._lock = threading.Lock() + self.start_time = time.time() + self.syncs_total = 0 + self.syncs_failed = 0 + self.syncs_skipped = 0 + self.total_added = 0 + self.total_updated = 0 + self.total_deleted = 0 + self.total_duration = 0.0 + self.total_latency_ms = 0.0 + self.non_skip_count = 0 + self.bandwidth_bytes = 0 + self.bandwidth_saved_bytes = 0 + self.history = deque(maxlen=50) + + def record(self, ok: bool, duration: float, added: int, updated: int, deleted: int, skipped: bool, ics_latency_ms: float, msg: str, ics_download_size: int = 0) -> None: + with self._lock: + self.syncs_total += 1 + ts = time.strftime("%H:%M:%S", time.gmtime()) + if not ok: + self.syncs_failed += 1 + if skipped: + self.syncs_skipped += 1 + self.total_added += added + self.total_updated += updated + self.total_deleted += deleted + if not skipped: + self.non_skip_count += 1 + self.total_duration += duration + self.total_latency_ms += ics_latency_ms + if ics_download_size: + self.bandwidth_bytes += ics_download_size + self.history.append({ + "time": ts, + "ok": ok, + "duration": round(duration, 2), + "added": added, + "updated": updated, + "deleted": deleted, + "skipped": skipped, + "latency_ms": round(ics_latency_ms), + "msg": msg, + }) + + def get_status(self) -> dict: + with self._lock: + uptime = time.time() - self.start_time + avg_dur = round(self.total_duration / self.non_skip_count, 2) if self.non_skip_count > 0 else 0 + avg_lat = round(self.total_latency_ms / self.non_skip_count) if self.non_skip_count > 0 else 0 + return { + "uptime_sec": round(uptime), + "syncs_total": self.syncs_total, + "syncs_failed": self.syncs_failed, + "syncs_skipped": self.syncs_skipped, + "total_added": self.total_added, + "total_updated": self.total_updated, + "total_deleted": self.total_deleted, + "avg_duration": avg_dur, + "avg_latency_ms": avg_lat, + "bandwidth_bytes": self.bandwidth_bytes, + "bandwidth_saved_bytes": self.bandwidth_saved_bytes, + "history": list(self.history), + } + + def add_saved_bandwidth(self, bytes_saved: int) -> None: + with self._lock: + self.bandwidth_saved_bytes += bytes_saved + + class HealthServer: def __init__(self, port: int = 8081): self.port = port diff --git a/static/dashboard.html b/static/dashboard.html new file mode 100644 index 0000000..a5e34f4 --- /dev/null +++ b/static/dashboard.html @@ -0,0 +1,731 @@ + + + + + +Baikal Sync Dashboard + + + +
+
+ + +
+ +
+
Loading…
+
+ +
+
Total Events
+
Total Added
+
Avg Events/Sync
+
Syncs Total
+
Avg Sync Time
+
Avg Latency
+
Bandwidth Saved
+
Backoff
+
+ +
+
Session Breakdown
+
+
+
+
+
+ + + +
+
+
+
+ +
+
Configuration
+ + + +
+
+ +
+
Sync History
+
+
No sync history yet.
+
+
+
+ + + + diff --git a/sync_calendar.py b/sync_calendar.py index b265ab0..7ce64de 100644 --- a/sync_calendar.py +++ b/sync_calendar.py @@ -12,7 +12,8 @@ from config import validate, HEADERS, Config from state import SyncState from diff import parse_ics_events, compute_diff, parse_ics_events_with_data from apply import apply_adds, apply_updates, apply_deletes -from health import HealthServer +from health import HealthServer, SyncSession +from dashboard import DashboardServer logger = logging.getLogger(__name__) shutdown_event = threading.Event() @@ -51,8 +52,23 @@ def find_calendar(client, config): return None -def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: +def sync_once( + state: SyncState, + health: HealthServer, + session: SyncSession, + config: Config, + dashboard: DashboardServer, +) -> bool: start_time = time.time() + ics_latency_ms = 0 + added = 0 + updated = 0 + deleted = 0 + skipped = False + ics_download_size = 0 + msg = "" + + dashboard.set_syncing(True) logger.info("Starting sync cycle...") try: @@ -68,17 +84,35 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: if remote_etag and cached_etag == remote_etag: logger.info("No changes detected (ETag match). Skipping sync.") + duration = time.time() - start_time + skipped = True + msg = "no changes (ETag)" + session.record(True, duration, 0, 0, 0, True, 0, msg) + health.update_status(datetime.now(timezone.utc), duration, True, 0) + dashboard.set_syncing(False) return True + dl_start = time.time() response = requests.get(config.ics_url, headers=HEADERS, timeout=30) response.raise_for_status() ics_text = response.text + dl_end = time.time() + ics_latency_ms = round((dl_end - dl_start) * 1000) + ics_download_size = len(ics_text) ics_hash = hashlib.sha256(ics_text.encode("utf-8")).hexdigest() if cached_hash == ics_hash: logger.info("No changes detected (hash match). Skipping sync.") if remote_etag: state.set_ics_cache(ics_hash, remote_etag) + duration = time.time() - start_time + skipped = True + msg = "no changes (hash)" + session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size) + if ics_download_size: + pass + health.update_status(datetime.now(timezone.utc), duration, True, 0) + dashboard.set_syncing(False) return True state.set_ics_cache(ics_hash, remote_etag) @@ -101,30 +135,29 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: if not to_add and not to_update and not to_delete: logger.info("Calendar is already in sync.") duration = time.time() - start_time - health.update_status( - datetime.now(timezone.utc), - duration, - True, - len(ics_uids), - ) + skipped = True + msg = "already in sync" + session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size) for uid, h in ics_uids.items(): state.upsert_event(uid, h) + health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids)) + dashboard.set_event_count(len(ics_uids)) + dashboard.set_syncing(False) return True if is_first_run and not to_delete: logger.info( - "First run detected: %d events in ICS. Registering state without re-adding to calendar.", + "First run detected: %d events in ICS. Registering state without re-adding.", len(to_add), ) for uid, h in ics_uids.items(): state.upsert_event(uid, h) duration = time.time() - start_time - health.update_status( - datetime.now(timezone.utc), - duration, - True, - len(ics_uids), - ) + msg = f"first run, registered {len(to_add)} events" + session.record(True, duration, len(to_add), 0, 0, False, ics_latency_ms, msg, ics_download_size) + health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids)) + dashboard.set_event_count(len(ics_uids)) + dashboard.set_syncing(False) return True logger.info( @@ -145,12 +178,10 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: if not calendar: logger.error("Failed to find calendar") duration = time.time() - start_time - health.update_status( - datetime.now(timezone.utc), - duration, - False, - 0, - ) + msg = "calendar not found" + session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) + health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_syncing(False) return False snapshot = state.snapshot() @@ -162,19 +193,26 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: try: logger.info("Phase 1: Adding %d events...", len(add_events)) + s_a, e_a = 0, 0 if add_events: - s, e = apply_adds(calendar, add_events) - logger.info("Added %d/%d events (%d errors)", s, len(add_events), e) + s_a, e_a = apply_adds(calendar, add_events) + logger.info("Added %d/%d events (%d errors)", s_a, len(add_events), e_a) logger.info("Phase 2: Updating %d events...", len(update_events)) + s_u, e_u = 0, 0 if update_events: - s, e = apply_updates(calendar, update_events) - logger.info("Updated %d/%d events (%d errors)", s, len(update_events), e) + s_u, e_u = apply_updates(calendar, update_events) + logger.info("Updated %d/%d events (%d errors)", s_u, len(update_events), e_u) logger.info("Phase 3: Deleting %d events...", len(delete_uids)) + s_d, e_d = 0, 0 if delete_uids: - s, e = apply_deletes(calendar, delete_uids) - logger.info("Deleted %d/%d events (%d errors)", s, len(delete_uids), e) + s_d, e_d = apply_deletes(calendar, delete_uids) + logger.info("Deleted %d/%d events (%d errors)", s_d, len(delete_uids), e_d) + + added = s_a + updated = s_u + deleted = s_d for uid, h in ics_uids.items(): state.upsert_event(uid, h) @@ -184,36 +222,31 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: total = len(ics_uids) duration = time.time() - start_time + msg = f"+{added} / ~{updated} / -{deleted}" + session.record(True, duration, added, updated, deleted, False, ics_latency_ms, msg, ics_download_size) logger.info("Sync completed in %.1fs. Total events: %d", duration, total) - health.update_status( - datetime.now(timezone.utc), - duration, - True, - total, - ) + health.update_status(datetime.now(timezone.utc), duration, True, total) + dashboard.set_event_count(total) + dashboard.set_syncing(False) return True except Exception as exc: logger.error("Sync failed: %s. Rolling back state.", exc) state.restore_snapshot(snapshot) duration = time.time() - start_time - health.update_status( - datetime.now(timezone.utc), - duration, - False, - 0, - ) + msg = str(exc)[:80] + session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) + health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_syncing(False) return False except Exception as exc: logger.error("Sync error: %s", exc) duration = time.time() - start_time - health.update_status( - datetime.now(timezone.utc), - duration, - False, - 0, - ) + msg = str(exc)[:80] + session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) + health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_syncing(False) return False @@ -233,6 +266,19 @@ def main(): health.start() logger.info("Health endpoint on :8081") + session = SyncSession() + dashboard = DashboardServer(8082, health, session) + dashboard.update_config({ + "ics_url": config.ics_url, + "baikal_url": config.baikal_url, + "baikal_user": config.baikal_user, + "baikal_pass": config.baikal_pass, + "sync_frequency": config.sync_frequency, + "calendar_id": os.environ.get("CALENDAR_ID", ""), + }) + dashboard.start() + logger.info("Dashboard on :8082") + backoff = 0 def handle_signal(signum, frame): @@ -243,7 +289,7 @@ def main(): signal.signal(signal.SIGINT, handle_signal) while not shutdown_event.is_set(): - success = sync_once(state, health, config) + success = sync_once(state, health, session, config, dashboard) if success: backoff = 0 @@ -253,11 +299,14 @@ def main(): sleep_time = backoff * 60 logger.info("Sync failed. Backing off %d minutes...", backoff) + dashboard.set_next_sync_in(sleep_time) + dashboard.set_backoff_min(backoff) logger.info("Next sync in %d seconds...", sleep_time) shutdown_event.wait(sleep_time) state.close() health.stop() + dashboard.stop() logger.info("Shutdown complete.") From 2c05502553e5dba482f8357114ef3de9f011fbf4 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Fri, 12 Jun 2026 10:06:20 +0200 Subject: [PATCH 7/7] fix: dashboard integration, remove port 8081, countdown persistence --- dashboard.py | 39 +++++++++++++++++++++++++-------------- docker-compose.yml | 1 - static/dashboard.html | 18 ++++++++++++------ sync_calendar.py | 10 +++++++++- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/dashboard.py b/dashboard.py index 11520f0..0f36efb 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,15 +1,20 @@ import json import os import threading +import time from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn -from health import HealthServer, SyncSession +from health import SyncSession + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True class DashboardServer: - def __init__(self, port: int = 8082, health: HealthServer = None, session: SyncSession = None): + def __init__(self, port: int = 8082, session: SyncSession = None): self.port = port - self.health = health self.session = session self.server = None self.thread = None @@ -19,11 +24,15 @@ class DashboardServer: self._event_count = 0 self._backoff_min = 0 self._config = {} + self._last_sync = None + self._last_duration = 0.0 + self._last_success = None + self._last_latency_ms = 0 self._base_dir = os.path.dirname(os.path.abspath(__file__)) def start(self): handler = self._make_handler() - self.server = HTTPServer(("0.0.0.0", self.port), handler) + self.server = ThreadingHTTPServer(("0.0.0.0", self.port), handler) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() @@ -53,6 +62,13 @@ class DashboardServer: with self._lock: self._backoff_min = n + def set_last_sync(self, last_sync, duration: float, success: bool, latency_ms: int = 0): + with self._lock: + self._last_sync = last_sync + self._last_duration = duration + self._last_success = success + self._last_latency_ms = latency_ms + def _get_status(self): with self._lock: syncing = self._syncing @@ -60,15 +76,10 @@ class DashboardServer: event_count = self._event_count backoff_min = self._backoff_min config = dict(self._config) - - last_sync = None - duration = 0.0 - last_success = None - if self.health: - with self.health.lock: - last_sync = self.health.last_sync - duration = self.health.last_sync_duration - last_success = self.health.last_sync_success + last_sync = self._last_sync + duration = self._last_duration + last_success = self._last_success + latency_ms = self._last_latency_ms status = "idle" if syncing: @@ -92,7 +103,7 @@ class DashboardServer: "status": status, "last_sync": last_sync.isoformat() if last_sync else None, "duration": duration, - "ics_latency_ms": ics_latency, + "ics_latency_ms": ics_latency if ics_latency else latency_ms, "event_count": event_count, "next_sync_in": next_sync_in, "session": session_data, diff --git a/docker-compose.yml b/docker-compose.yml index 0477a35..6670ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,6 @@ services: container_name: baikal-sync restart: always ports: - - "8081:8081" - "8082:8082" environment: - ICS_URL=${ICS_URL} diff --git a/static/dashboard.html b/static/dashboard.html index a5e34f4..2d31a41 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -333,6 +333,8 @@ header { lastPoll: null }; + let countdownRemaining = 0; + const $ = (sel) => document.querySelector(sel); function fmtBytes(b) { @@ -502,7 +504,7 @@ header { $("#stackedLegend").innerHTML = '
Added: ' + added + "
" + '
Updated: ' + updated + "
" + - '
Deleted: " + deleted + "
" + + '
Deleted: ' + deleted + "
" + '
Skipped: ' + skipped + "
"; } @@ -673,14 +675,12 @@ header { const fill = $("#progressFill"); if (!el || !fill) return; - let remaining = (state.data.next_sync_in || 0) - 1; - if (remaining < 0) remaining = 0; - el.textContent = fmtCountdown(remaining); + countdownRemaining = Math.max(0, countdownRemaining - 1); + el.textContent = fmtCountdown(countdownRemaining); const freq = state.data.config ? state.data.config.sync_frequency * 60 : 600; - const pct = freq > 0 ? Math.max(0, Math.min(100, ((freq - remaining) / freq) * 100)) : 100; + const pct = freq > 0 ? Math.max(0, Math.min(100, ((freq - countdownRemaining) / freq) * 100)) : 100; fill.style.width = pct + "%"; - state.data.next_sync_in = remaining; } async function poll() { @@ -689,6 +689,12 @@ header { if (!res.ok) throw new Error("HTTP " + res.status); state.data = await res.json(); state.lastPoll = Date.now(); + if (state.data.last_sync && state.data.config && state.data.config.sync_frequency) { + const syncEpoch = new Date(state.data.last_sync).getTime(); + const freqSec = state.data.config.sync_frequency * 60; + const now = Date.now(); + countdownRemaining = Math.max(0, Math.round(((syncEpoch + freqSec * 1000) - now) / 1000)); + } renderStatusBar(state.data); renderStats(state.data); renderStackedBar(state.data); diff --git a/sync_calendar.py b/sync_calendar.py index 7ce64de..4d8c11e 100644 --- a/sync_calendar.py +++ b/sync_calendar.py @@ -89,6 +89,7 @@ def sync_once( msg = "no changes (ETag)" session.record(True, duration, 0, 0, 0, True, 0, msg) health.update_status(datetime.now(timezone.utc), duration, True, 0) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, 0) dashboard.set_syncing(False) return True @@ -112,6 +113,7 @@ def sync_once( if ics_download_size: pass health.update_status(datetime.now(timezone.utc), duration, True, 0) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms) dashboard.set_syncing(False) return True @@ -141,6 +143,7 @@ def sync_once( for uid, h in ics_uids.items(): state.upsert_event(uid, h) health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids)) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms) dashboard.set_event_count(len(ics_uids)) dashboard.set_syncing(False) return True @@ -156,6 +159,7 @@ def sync_once( msg = f"first run, registered {len(to_add)} events" session.record(True, duration, len(to_add), 0, 0, False, ics_latency_ms, msg, ics_download_size) health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids)) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms) dashboard.set_event_count(len(ics_uids)) dashboard.set_syncing(False) return True @@ -181,6 +185,7 @@ def sync_once( msg = "calendar not found" session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms) dashboard.set_syncing(False) return False @@ -226,6 +231,7 @@ def sync_once( session.record(True, duration, added, updated, deleted, False, ics_latency_ms, msg, ics_download_size) logger.info("Sync completed in %.1fs. Total events: %d", duration, total) health.update_status(datetime.now(timezone.utc), duration, True, total) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms) dashboard.set_event_count(total) dashboard.set_syncing(False) return True @@ -237,6 +243,7 @@ def sync_once( msg = str(exc)[:80] session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms) dashboard.set_syncing(False) return False @@ -246,6 +253,7 @@ def sync_once( msg = str(exc)[:80] session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size) health.update_status(datetime.now(timezone.utc), duration, False, 0) + dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms) dashboard.set_syncing(False) return False @@ -267,7 +275,7 @@ def main(): logger.info("Health endpoint on :8081") session = SyncSession() - dashboard = DashboardServer(8082, health, session) + dashboard = DashboardServer(8082, session) dashboard.update_config({ "ics_url": config.ics_url, "baikal_url": config.baikal_url,