feat: dashboard UI with real-time stats, light/dark mode, session metrics

This commit is contained in:
2026-06-12 08:11:24 +02:00
parent 20247c92c2
commit 7a388653ed
6 changed files with 1050 additions and 46 deletions
+95 -46
View File
@@ -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.")