feat: dashboard UI with real-time stats, light/dark mode, session metrics
This commit is contained in:
+95
-46
@@ -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.")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user