diff --git a/README.md b/README.md index 0116c07..c7cc293 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A FastAPI-based uptime monitoring service that allows you to monitor website/end ## Features - **Monitor Management**: Create, update, delete, and list website monitors +- **Automatic Scheduling**: Background scheduler runs uptime checks at configured intervals - **Uptime Checking**: Automated and manual uptime checks with response time tracking - **Statistics**: Get uptime percentage, average response times, and check history - **RESTful API**: Full REST API with OpenAPI documentation @@ -31,6 +32,12 @@ A FastAPI-based uptime monitoring service that allows you to monitor website/end - `POST /api/v1/checks/run/{monitor_id}` - Run check for specific monitor - `POST /api/v1/checks/run-all` - Run checks for all active monitors +### Scheduler Endpoints +- `GET /api/v1/scheduler/status` - Get scheduler status and running jobs +- `POST /api/v1/scheduler/start` - Start the background scheduler +- `POST /api/v1/scheduler/stop` - Stop the background scheduler +- `POST /api/v1/scheduler/restart` - Restart scheduler and reschedule all monitors + ## Installation & Setup 1. Install dependencies: @@ -76,6 +83,11 @@ curl -X POST "http://localhost:8000/api/v1/checks/run/1" curl "http://localhost:8000/api/v1/monitors/1/stats" ``` +### Check Scheduler Status +```bash +curl "http://localhost:8000/api/v1/scheduler/status" +``` + ## Monitor Configuration When creating a monitor, you can configure: @@ -87,6 +99,16 @@ When creating a monitor, you can configure: - **interval**: Check interval in seconds - defaults to 300 (5 minutes) - **is_active**: Whether the monitor is active - defaults to true +## Automatic Scheduling + +The API includes a background scheduler that automatically runs uptime checks for all active monitors: + +- **Automatic Start**: The scheduler starts automatically when the application launches +- **Individual Intervals**: Each monitor runs on its own configured interval (default 5 minutes) +- **Real-time Updates**: Creating, updating, or deleting monitors automatically adjusts the scheduler +- **Logging**: All check results are logged with timestamps and status information +- **Scheduler Management**: Use the scheduler endpoints to check status, start, stop, or restart the scheduler + ## Database The application uses SQLite database located at `/app/storage/db/db.sqlite`. The database contains: diff --git a/app/routers/monitors.py b/app/routers/monitors.py index d72b8db..5f7d195 100644 --- a/app/routers/monitors.py +++ b/app/routers/monitors.py @@ -11,6 +11,7 @@ from app.models.schemas import ( UptimeCheckResponse, MonitorStats, ) +from app.services.scheduler import uptime_scheduler router = APIRouter(prefix="/monitors", tags=["monitors"]) @@ -23,6 +24,13 @@ def create_monitor(monitor: MonitorCreate, db: Session = Depends(get_db)): db.add(db_monitor) db.commit() db.refresh(db_monitor) + + # Schedule the monitor if it's active + if db_monitor.is_active: + uptime_scheduler.add_monitor_job( + db_monitor.id, db_monitor.interval, db_monitor.name + ) + return db_monitor @@ -56,6 +64,12 @@ def update_monitor( db.commit() db.refresh(db_monitor) + + # Update the scheduled job + uptime_scheduler.update_monitor_job( + db_monitor.id, db_monitor.interval, db_monitor.name, db_monitor.is_active + ) + return db_monitor @@ -65,6 +79,9 @@ def delete_monitor(monitor_id: int, db: Session = Depends(get_db)): if not monitor: raise HTTPException(status_code=404, detail="Monitor not found") + # Remove the scheduled job + uptime_scheduler.remove_monitor_job(monitor_id) + db.delete(monitor) db.commit() return {"message": "Monitor deleted successfully"} diff --git a/app/routers/scheduler.py b/app/routers/scheduler.py new file mode 100644 index 0000000..bee363e --- /dev/null +++ b/app/routers/scheduler.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter +from app.services.scheduler import uptime_scheduler + +router = APIRouter(prefix="/scheduler", tags=["scheduler"]) + + +@router.get("/status") +def get_scheduler_status(): + """Get the current status of the scheduler and all running jobs""" + if not uptime_scheduler.is_running: + return {"status": "stopped", "jobs": []} + + jobs = [] + for job in uptime_scheduler.scheduler.get_jobs(): + jobs.append( + { + "id": job.id, + "name": job.name, + "next_run_time": job.next_run_time.isoformat() + if job.next_run_time + else None, + "trigger": str(job.trigger), + } + ) + + return {"status": "running", "total_jobs": len(jobs), "jobs": jobs} + + +@router.post("/start") +def start_scheduler(): + """Start the scheduler""" + if uptime_scheduler.is_running: + return {"message": "Scheduler is already running"} + + uptime_scheduler.start() + return {"message": "Scheduler started successfully"} + + +@router.post("/stop") +def stop_scheduler(): + """Stop the scheduler""" + if not uptime_scheduler.is_running: + return {"message": "Scheduler is already stopped"} + + uptime_scheduler.stop() + return {"message": "Scheduler stopped successfully"} + + +@router.post("/restart") +def restart_scheduler(): + """Restart the scheduler and reschedule all monitors""" + uptime_scheduler.stop() + uptime_scheduler.start() + return {"message": "Scheduler restarted successfully"} diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..90b1c1c --- /dev/null +++ b/app/services/scheduler.py @@ -0,0 +1,134 @@ +import logging +from datetime import datetime, timedelta +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy.orm import sessionmaker +from app.db.session import engine +from app.models.monitor import Monitor +from app.services.uptime_checker import UptimeChecker + +logger = logging.getLogger(__name__) + + +class UptimeScheduler: + def __init__(self): + self.scheduler = BackgroundScheduler() + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + self.is_running = False + + def start(self): + if not self.is_running: + self.scheduler.start() + self.is_running = True + logger.info("Uptime scheduler started") + self.schedule_all_monitors() + + def stop(self): + if self.is_running: + self.scheduler.shutdown() + self.is_running = False + logger.info("Uptime scheduler stopped") + + def schedule_all_monitors(self): + """Schedule all active monitors based on their individual intervals""" + db = self.SessionLocal() + try: + active_monitors = db.query(Monitor).filter(Monitor.is_active).all() + + for monitor in active_monitors: + job_id = f"monitor_{monitor.id}" + + # Remove existing job if it exists + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + + # Add new job with the monitor's interval + self.scheduler.add_job( + func=self.check_monitor, + trigger=IntervalTrigger(seconds=monitor.interval), + id=job_id, + args=[monitor.id], + name=f"Check {monitor.name}", + replace_existing=True, + next_run_time=datetime.now() + + timedelta(seconds=10), # Start after 10 seconds + ) + + logger.info( + f"Scheduled monitor '{monitor.name}' (ID: {monitor.id}) to run every {monitor.interval} seconds" + ) + + except Exception as e: + logger.error(f"Error scheduling monitors: {e}") + finally: + db.close() + + def check_monitor(self, monitor_id: int): + """Run uptime check for a specific monitor""" + db = self.SessionLocal() + try: + monitor = db.query(Monitor).filter(Monitor.id == monitor_id).first() + if monitor and monitor.is_active: + checker = UptimeChecker(db) + result = checker.check_monitor(monitor) + + status = "UP" if result["is_up"] else "DOWN" + logger.info( + f"Monitor '{monitor.name}' (ID: {monitor_id}): {status} - Response time: {result['response_time']}ms" + ) + else: + # Monitor was deleted or deactivated, remove the job + job_id = f"monitor_{monitor_id}" + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + logger.info( + f"Removed job for inactive/deleted monitor ID: {monitor_id}" + ) + + except Exception as e: + logger.error(f"Error checking monitor {monitor_id}: {e}") + finally: + db.close() + + def add_monitor_job(self, monitor_id: int, interval: int, name: str): + """Add a job for a new monitor""" + job_id = f"monitor_{monitor_id}" + + # Remove existing job if it exists + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + + # Add new job + self.scheduler.add_job( + func=self.check_monitor, + trigger=IntervalTrigger(seconds=interval), + id=job_id, + args=[monitor_id], + name=f"Check {name}", + replace_existing=True, + next_run_time=datetime.now() + timedelta(seconds=10), + ) + + logger.info( + f"Added scheduler job for monitor '{name}' (ID: {monitor_id}) with {interval} second interval" + ) + + def remove_monitor_job(self, monitor_id: int): + """Remove a job for a deleted monitor""" + job_id = f"monitor_{monitor_id}" + if self.scheduler.get_job(job_id): + self.scheduler.remove_job(job_id) + logger.info(f"Removed scheduler job for monitor ID: {monitor_id}") + + def update_monitor_job( + self, monitor_id: int, interval: int, name: str, is_active: bool + ): + """Update a job for a modified monitor""" + if is_active: + self.add_monitor_job(monitor_id, interval, name) + else: + self.remove_monitor_job(monitor_id) + + +# Global scheduler instance +uptime_scheduler = UptimeScheduler() diff --git a/main.py b/main.py index b306d2a..a219289 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,37 @@ +import logging +from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.db.session import engine from app.db.base import Base -from app.routers import monitors, checks +from app.routers import monitors, checks, scheduler +from app.services.scheduler import uptime_scheduler + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) # Create database tables Base.metadata.create_all(bind=engine) + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info("Starting Uptime Monitoring API...") + uptime_scheduler.start() + yield + # Shutdown + logger.info("Shutting down Uptime Monitoring API...") + uptime_scheduler.stop() + + app = FastAPI( title="Uptime Monitoring API", description="API for monitoring website/endpoint uptime and performance", version="1.0.0", openapi_url="/openapi.json", + lifespan=lifespan, ) app.add_middleware( @@ -25,6 +45,7 @@ app.add_middleware( # Include routers app.include_router(monitors.router, prefix="/api/v1") app.include_router(checks.router, prefix="/api/v1") +app.include_router(scheduler.router, prefix="/api/v1") @app.get("/") diff --git a/requirements.txt b/requirements.txt index 9427a1c..03cb16b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ alembic==1.12.1 pydantic==2.5.0 requests==2.31.0 python-multipart==0.0.6 -ruff==0.1.6 \ No newline at end of file +ruff==0.1.6 +apscheduler==3.10.4 \ No newline at end of file