Add automatic background scheduler for uptime monitoring

- Implemented APScheduler for automatic uptime checks
- Each monitor runs on its own configured interval (default 5 minutes)
- Scheduler starts automatically on application startup
- Real-time job management when monitors are created/updated/deleted
- Added scheduler status and control endpoints
- Background logging of all check results
- Updated documentation with scheduling features
This commit is contained in:
Automated Action 2025-06-20 11:33:47 +00:00
parent 42915817b3
commit 1501f02451
6 changed files with 251 additions and 2 deletions

View File

@ -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:

View File

@ -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"}

54
app/routers/scheduler.py Normal file
View File

@ -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"}

134
app/services/scheduler.py Normal file
View File

@ -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()

23
main.py
View File

@ -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("/")

View File

@ -6,3 +6,4 @@ pydantic==2.5.0
requests==2.31.0
python-multipart==0.0.6
ruff==0.1.6
apscheduler==3.10.4