diff --git a/data/garmin.db b/data/garmin.db index 6c65413..bd5918c 100644 Binary files a/data/garmin.db and b/data/garmin.db differ diff --git a/garminsync/daemon.py b/garminsync/daemon.py index 664e2eb..b7b095b 100644 --- a/garminsync/daemon.py +++ b/garminsync/daemon.py @@ -19,13 +19,13 @@ class GarminSyncDaemon: """Start daemon with scheduler and web UI""" try: # Load configuration from database - config = self.load_config() + config_data = self.load_config() # Setup scheduled job - if config.enabled: + if config_data['enabled']: self.scheduler.add_job( func=self.sync_and_download, - trigger=CronTrigger.from_crontab(config.schedule_cron), + trigger=CronTrigger.from_crontab(config_data['schedule_cron']), id='sync_job', replace_existing=True ) @@ -34,6 +34,9 @@ class GarminSyncDaemon: self.scheduler.start() self.running = True + # Update daemon status to running + self.update_daemon_status("running") + # Start web UI in separate thread self.start_web_ui(web_port) @@ -49,10 +52,12 @@ class GarminSyncDaemon: except Exception as e: logger.error(f"Failed to start daemon: {str(e)}") + self.update_daemon_status("error") self.stop() def sync_and_download(self): """Scheduled job function""" + session = None try: self.log_operation("sync", "started") @@ -99,36 +104,85 @@ class GarminSyncDaemon: logger.error(f"Failed to download activity {activity.activity_id}: {e}") session.rollback() - session.close() self.log_operation("sync", "success", f"Downloaded {downloaded_count} new activities") + # Update last run time + self.update_daemon_last_run() + except Exception as e: logger.error(f"Sync failed: {e}") self.log_operation("sync", "error", str(e)) + finally: + if session: + session.close() def load_config(self): - """Load daemon configuration from database""" + """Load daemon configuration from database and return dict""" session = get_session() - config = session.query(DaemonConfig).first() - if not config: - # Create default configuration - config = DaemonConfig() - session.add(config) + try: + config = session.query(DaemonConfig).first() + if not config: + # Create default configuration + config = DaemonConfig() + session.add(config) + session.commit() + session.refresh(config) # Ensure we have the latest data + + # Return configuration as dictionary to avoid session issues + return { + 'id': config.id, + 'enabled': config.enabled, + 'schedule_cron': config.schedule_cron, + 'last_run': config.last_run, + 'next_run': config.next_run, + 'status': config.status + } + finally: + session.close() + + def update_daemon_status(self, status): + """Update daemon status in database""" + session = get_session() + try: + config = session.query(DaemonConfig).first() + if not config: + config = DaemonConfig() + session.add(config) + + config.status = status session.commit() - return config + finally: + session.close() + + def update_daemon_last_run(self): + """Update daemon last run timestamp""" + session = get_session() + try: + config = session.query(DaemonConfig).first() + if config: + config.last_run = datetime.now().isoformat() + session.commit() + finally: + session.close() def start_web_ui(self, port): """Start FastAPI web server in a separate thread""" - from .web.app import app - import uvicorn - - def run_server(): - uvicorn.run(app, host="0.0.0.0", port=port) + try: + from .web.app import app + import uvicorn - web_thread = threading.Thread(target=run_server, daemon=True) - web_thread.start() - self.web_server = web_thread + def run_server(): + try: + uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") + except Exception as e: + logger.error(f"Failed to start web server: {e}") + + web_thread = threading.Thread(target=run_server, daemon=True) + web_thread.start() + self.web_server = web_thread + except ImportError as e: + logger.warning(f"Could not start web UI: {e}") def signal_handler(self, signum, frame): """Handle shutdown signals""" @@ -140,21 +194,33 @@ class GarminSyncDaemon: if self.scheduler.running: self.scheduler.shutdown() self.running = False + self.update_daemon_status("stopped") + self.log_operation("daemon", "stopped", "Daemon shutdown completed") logger.info("Daemon stopped") def log_operation(self, operation, status, message=None): """Log sync operation to database""" session = get_session() - log = SyncLog( - timestamp=datetime.now().isoformat(), - operation=operation, - status=status, - message=message - ) - session.add(log) - session.commit() + try: + log = SyncLog( + timestamp=datetime.now().isoformat(), + operation=operation, + status=status, + message=message, + activities_processed=0, # Can be updated later if needed + activities_downloaded=0 # Can be updated later if needed + ) + session.add(log) + session.commit() + except Exception as e: + logger.error(f"Failed to log operation: {e}") + finally: + session.close() def count_missing(self): """Count missing activities""" session = get_session() - return session.query(Activity).filter_by(downloaded=False).count() + try: + return session.query(Activity).filter_by(downloaded=False).count() + finally: + session.close() \ No newline at end of file diff --git a/garminsync/web/app.py b/garminsync/web/app.py index d4d3527..e1c0d0a 100644 --- a/garminsync/web/app.py +++ b/garminsync/web/app.py @@ -1,24 +1,88 @@ from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from fastapi.responses import JSONResponse +import os +from pathlib import Path from .routes import router app = FastAPI(title="GarminSync Dashboard") -# Mount static files and templates -app.mount("/static", StaticFiles(directory="garminsync/web/static"), name="static") -templates = Jinja2Templates(directory="garminsync/web/templates") +# Get the current directory path +current_dir = Path(__file__).parent + +# Mount static files and templates with error handling +static_dir = current_dir / "static" +templates_dir = current_dir / "templates" + +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +if templates_dir.exists(): + templates = Jinja2Templates(directory=str(templates_dir)) +else: + templates = None # Include API routes app.include_router(router) @app.get("/") async def dashboard(request: Request): - # Get current statistics - from garminsync.database import get_offline_stats - stats = get_offline_stats() + """Dashboard route with fallback for missing templates""" + if not templates: + # Return JSON response if templates are not available + from garminsync.database import get_offline_stats + stats = get_offline_stats() + return JSONResponse({ + "message": "GarminSync Dashboard", + "stats": stats, + "note": "Web UI templates not found, showing JSON response" + }) - return templates.TemplateResponse("dashboard.html", { - "request": request, - "stats": stats + try: + # Get current statistics + from garminsync.database import get_offline_stats + stats = get_offline_stats() + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "stats": stats + }) + except Exception as e: + return JSONResponse({ + "error": f"Failed to load dashboard: {str(e)}", + "message": "Dashboard unavailable, API endpoints still functional" + }) + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "service": "GarminSync Dashboard"} + +@app.get("/config") +async def config_page(request: Request): + """Configuration page""" + if not templates: + return JSONResponse({ + "message": "Configuration endpoint", + "note": "Use /api/schedule endpoints for configuration" + }) + + return templates.TemplateResponse("config.html", { + "request": request }) + +# Error handlers +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + return JSONResponse( + status_code=404, + content={"error": "Not found", "path": str(request.url.path)} + ) + +@app.exception_handler(500) +async def server_error_handler(request: Request, exc): + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)} + ) \ No newline at end of file diff --git a/garminsync/web/routes.py b/garminsync/web/routes.py index 81ba988..13bd05a 100644 --- a/garminsync/web/routes.py +++ b/garminsync/web/routes.py @@ -12,45 +12,152 @@ class ScheduleConfig(BaseModel): async def get_status(): """Get current daemon status""" session = get_session() - config = session.query(DaemonConfig).first() - - # Get recent logs - logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(10).all() - - return { - "daemon": { + try: + config = session.query(DaemonConfig).first() + + # Get recent logs + logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(10).all() + + # Convert to dictionaries to avoid session issues + daemon_data = { "running": config.status == "running" if config else False, "next_run": config.next_run if config else None, - "schedule": config.schedule_cron if config else None - }, - "recent_logs": [ - { + "schedule": config.schedule_cron if config else None, + "last_run": config.last_run if config else None, + "enabled": config.enabled if config else False + } + + log_data = [] + for log in logs: + log_data.append({ "timestamp": log.timestamp, "operation": log.operation, "status": log.status, - "message": log.message - } for log in logs - ] - } + "message": log.message, + "activities_processed": log.activities_processed, + "activities_downloaded": log.activities_downloaded + }) + + return { + "daemon": daemon_data, + "recent_logs": log_data + } + finally: + session.close() @router.post("/schedule") async def update_schedule(config: ScheduleConfig): """Update daemon schedule configuration""" session = get_session() - daemon_config = session.query(DaemonConfig).first() - - if not daemon_config: - daemon_config = DaemonConfig() - session.add(daemon_config) - - daemon_config.enabled = config.enabled - daemon_config.schedule_cron = config.cron_schedule - session.commit() - - return {"message": "Configuration updated successfully"} + try: + daemon_config = session.query(DaemonConfig).first() + + if not daemon_config: + daemon_config = DaemonConfig() + session.add(daemon_config) + + daemon_config.enabled = config.enabled + daemon_config.schedule_cron = config.cron_schedule + session.commit() + + return {"message": "Configuration updated successfully"} + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}") + finally: + session.close() @router.post("/sync/trigger") async def trigger_sync(): """Manually trigger a sync operation""" - # TODO: Implement sync triggering - return {"message": "Sync triggered successfully"} + try: + # Import here to avoid circular imports + from garminsync.garmin import GarminClient + from garminsync.database import sync_database, Activity + from datetime import datetime + import os + from pathlib import Path + + # Create client and sync + client = GarminClient() + sync_database(client) + + # Download missing activities + session = get_session() + try: + missing_activities = session.query(Activity).filter_by(downloaded=False).all() + downloaded_count = 0 + + data_dir = Path(os.getenv("DATA_DIR", "data")) + data_dir.mkdir(parents=True, exist_ok=True) + + for activity in missing_activities: + try: + fit_data = client.download_activity_fit(activity.activity_id) + + timestamp = activity.start_time.replace(":", "-").replace(" ", "_") + filename = f"activity_{activity.activity_id}_{timestamp}.fit" + filepath = data_dir / filename + + with open(filepath, "wb") as f: + f.write(fit_data) + + activity.filename = str(filepath) + activity.downloaded = True + activity.last_sync = datetime.now().isoformat() + downloaded_count += 1 + session.commit() + + except Exception as e: + print(f"Failed to download activity {activity.activity_id}: {e}") + session.rollback() + + return {"message": f"Sync completed successfully. Downloaded {downloaded_count} activities."} + finally: + session.close() + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") + +@router.get("/activities/stats") +async def get_activity_stats(): + """Get activity statistics""" + from garminsync.database import get_offline_stats + return get_offline_stats() + +@router.get("/logs") +async def get_logs(limit: int = 50): + """Get recent sync logs""" + session = get_session() + try: + logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(limit).all() + + log_data = [] + for log in logs: + log_data.append({ + "id": log.id, + "timestamp": log.timestamp, + "operation": log.operation, + "status": log.status, + "message": log.message, + "activities_processed": log.activities_processed, + "activities_downloaded": log.activities_downloaded + }) + + return {"logs": log_data} + finally: + session.close() + +@router.delete("/logs") +async def clear_logs(): + """Clear all sync logs""" + session = get_session() + try: + session.query(SyncLog).delete() + session.commit() + return {"message": "Logs cleared successfully"} + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Failed to clear logs: {str(e)}") + finally: + session.close() \ No newline at end of file