Initial commit: Claude Code Monitor v1.0.0
Complete real-time monitoring dashboard for Claude Code Features: - FastAPI backend with REST API and WebSocket - React + TypeScript frontend with dark theme - SQLite database for event storage - 6 hook scripts for Claude Code events - Docker deployment setup - Real-time event tracking and statistics - Event filtering (ALL, TOOLS, AGENTS, PROMPTS, SESSIONS) - Connection status indicator - Reset stats functionality Tech Stack: - Backend: Python 3.11, FastAPI, SQLAlchemy, WebSockets - Frontend: React 18, TypeScript, Vite - Database: SQLite - Deployment: Docker, Docker Compose 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
139
backend/app/api/events.py
Normal file
139
backend/app/api/events.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""API endpoints for event management."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import EventCreate, EventResponse, EventListResponse
|
||||
from app.crud import create_event, get_events, get_event_by_id, delete_all_events, get_statistics
|
||||
from app.websocket import manager
|
||||
|
||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||
|
||||
|
||||
@router.post("", response_model=EventResponse, status_code=201)
|
||||
async def create_event_endpoint(event: EventCreate, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Create a new event.
|
||||
|
||||
This endpoint is called by Claude Code hook scripts to report events.
|
||||
"""
|
||||
try:
|
||||
# Create event in database
|
||||
db_event = create_event(db, event)
|
||||
|
||||
# Convert event to dict for WebSocket
|
||||
event_dict = {
|
||||
"id": db_event.id,
|
||||
"session_id": db_event.session_id,
|
||||
"event_type": db_event.event_type,
|
||||
"tool_name": db_event.tool_name,
|
||||
"tool_input": json.loads(db_event.tool_input) if db_event.tool_input else None,
|
||||
"tool_output": db_event.tool_output,
|
||||
"success": db_event.success,
|
||||
"description": db_event.description,
|
||||
"timestamp": db_event.timestamp,
|
||||
"created_at": db_event.created_at.isoformat() if db_event.created_at else None,
|
||||
}
|
||||
|
||||
# Get updated statistics
|
||||
stats = get_statistics(db)
|
||||
stats_dict = {
|
||||
"total_events": stats.total_events,
|
||||
"total_tools_used": stats.total_tools_used,
|
||||
"total_agents": stats.total_agents,
|
||||
"total_sessions": stats.total_sessions,
|
||||
"last_updated": stats.last_updated,
|
||||
}
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
await manager.broadcast_event(event_dict, stats_dict)
|
||||
|
||||
return db_event
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error creating event: {str(e)}")
|
||||
|
||||
|
||||
@router.get("", response_model=EventListResponse)
|
||||
async def list_events(
|
||||
skip: int = Query(0, ge=0, description="Number of events to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of events to return"),
|
||||
event_type: Optional[str] = Query(None, description="Filter by event type (comma-separated for multiple)"),
|
||||
session_id: Optional[str] = Query(None, description="Filter by session ID"),
|
||||
tool_name: Optional[str] = Query(None, description="Filter by tool name"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get a paginated list of events with optional filtering.
|
||||
|
||||
Supports filtering by:
|
||||
- event_type: Single type or comma-separated list (e.g., "PreToolUse,PostToolUse")
|
||||
- session_id: Filter events from a specific session
|
||||
- tool_name: Filter events for a specific tool
|
||||
"""
|
||||
try:
|
||||
events, total = get_events(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
event_type=event_type,
|
||||
session_id=session_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
event_responses = []
|
||||
for event in events:
|
||||
event_dict = {
|
||||
"id": event.id,
|
||||
"session_id": event.session_id,
|
||||
"event_type": event.event_type,
|
||||
"tool_name": event.tool_name,
|
||||
"tool_input": json.loads(event.tool_input) if event.tool_input else None,
|
||||
"tool_output": event.tool_output,
|
||||
"success": event.success,
|
||||
"description": event.description,
|
||||
"timestamp": event.timestamp,
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
event_responses.append(EventResponse(**event_dict))
|
||||
|
||||
return EventListResponse(
|
||||
events=event_responses,
|
||||
total=total,
|
||||
page=skip // limit + 1,
|
||||
page_size=limit,
|
||||
has_more=(skip + limit) < total,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching events: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=EventResponse)
|
||||
async def get_event(event_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a single event by ID."""
|
||||
event = get_event_by_id(db, event_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@router.delete("/reset", status_code=204)
|
||||
async def reset_stats(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Reset all statistics by deleting all events.
|
||||
|
||||
This is used by the "Reset Stats" button in the frontend.
|
||||
"""
|
||||
try:
|
||||
delete_all_events(db)
|
||||
|
||||
# Broadcast reset notification to all clients
|
||||
await manager.broadcast_stats_reset()
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error resetting stats: {str(e)}")
|
||||
50
backend/app/api/statistics.py
Normal file
50
backend/app/api/statistics.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""API endpoints for statistics."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import StatisticsResponse, ToolUsageResponse, SessionResponse
|
||||
from app.crud import get_statistics, get_tool_usage, get_sessions
|
||||
|
||||
router = APIRouter(prefix="/api/statistics", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_stats(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get current statistics.
|
||||
|
||||
Returns aggregated statistics including:
|
||||
- Total events count
|
||||
- Number of unique tools used
|
||||
- Number of agents spawned
|
||||
- Number of unique sessions
|
||||
- Last update timestamp
|
||||
"""
|
||||
stats = get_statistics(db)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/tools", response_model=List[ToolUsageResponse])
|
||||
async def get_tool_stats(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get tool usage statistics.
|
||||
|
||||
Returns a list of all tools used with their usage counts and timestamps.
|
||||
Sorted by usage count (descending).
|
||||
"""
|
||||
tools = get_tool_usage(db)
|
||||
return tools
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=List[SessionResponse])
|
||||
async def get_sessions_list(limit: int = 100, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get list of sessions.
|
||||
|
||||
Returns recent sessions with their metadata.
|
||||
"""
|
||||
sessions = get_sessions(db, limit=limit)
|
||||
return sessions
|
||||
65
backend/app/api/websocket_endpoint.py
Normal file
65
backend/app/api/websocket_endpoint.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""WebSocket API endpoint for real-time updates."""
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
import json
|
||||
|
||||
from app.websocket import manager
|
||||
from app.database import get_db
|
||||
from app.crud import get_statistics
|
||||
|
||||
router = APIRouter(tags=["websocket"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket endpoint for real-time event updates.
|
||||
|
||||
Clients connect to this endpoint to receive:
|
||||
- New events as they're created
|
||||
- Updated statistics
|
||||
- Reset notifications
|
||||
|
||||
Message types:
|
||||
- event_created: New event with updated statistics
|
||||
- stats_updated: Statistics update
|
||||
- stats_reset: All stats have been reset
|
||||
- connection_status: Connection status message
|
||||
"""
|
||||
await manager.connect(websocket)
|
||||
|
||||
try:
|
||||
# Send initial connection message
|
||||
await manager.send_personal_message(
|
||||
{"type": "connection_status", "message": "Connected to Claude Code Monitor"},
|
||||
websocket,
|
||||
)
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
# Receive messages from client (for future features like pings)
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
|
||||
# Handle ping/pong for connection health
|
||||
if message.get("type") == "ping":
|
||||
await manager.send_personal_message(
|
||||
{"type": "pong", "timestamp": message.get("timestamp")},
|
||||
websocket,
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Client disconnected")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling WebSocket message: {e}")
|
||||
# Don't break on error, continue listening
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
246
backend/app/crud.py
Normal file
246
backend/app/crud.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""CRUD operations for database access."""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func, or_
|
||||
from typing import List, Optional, Tuple
|
||||
import time
|
||||
import json
|
||||
|
||||
from app.database import Event, Session as SessionModel, Statistics, ToolUsage
|
||||
from app.schemas import EventCreate
|
||||
|
||||
|
||||
def create_event(db: Session, event: EventCreate) -> Event:
|
||||
"""
|
||||
Create a new event and update statistics.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event: Event data to create
|
||||
|
||||
Returns:
|
||||
Created event object
|
||||
"""
|
||||
# Convert tool_input to JSON string if it's a dict
|
||||
tool_input_str = None
|
||||
if event.tool_input is not None:
|
||||
if isinstance(event.tool_input, str):
|
||||
tool_input_str = event.tool_input
|
||||
else:
|
||||
tool_input_str = json.dumps(event.tool_input)
|
||||
|
||||
# Generate description if not provided
|
||||
description = event.description
|
||||
if not description:
|
||||
description = _generate_description(event)
|
||||
|
||||
# Create event
|
||||
db_event = Event(
|
||||
session_id=event.session_id,
|
||||
event_type=event.event_type,
|
||||
tool_name=event.tool_name,
|
||||
tool_input=tool_input_str,
|
||||
tool_output=event.tool_output,
|
||||
success=event.success,
|
||||
description=description,
|
||||
timestamp=event.timestamp,
|
||||
)
|
||||
db.add(db_event)
|
||||
|
||||
# Update or create session
|
||||
session = db.query(SessionModel).filter(SessionModel.session_id == event.session_id).first()
|
||||
if not session:
|
||||
session = SessionModel(
|
||||
session_id=event.session_id,
|
||||
started_at=event.timestamp,
|
||||
event_count=1,
|
||||
)
|
||||
db.add(session)
|
||||
else:
|
||||
session.event_count += 1
|
||||
if event.event_type == "SessionEnd":
|
||||
session.ended_at = event.timestamp
|
||||
|
||||
# Update tool usage for tool events
|
||||
if event.tool_name:
|
||||
tool_usage = db.query(ToolUsage).filter(ToolUsage.tool_name == event.tool_name).first()
|
||||
if not tool_usage:
|
||||
tool_usage = ToolUsage(
|
||||
tool_name=event.tool_name,
|
||||
usage_count=1,
|
||||
first_used=event.timestamp,
|
||||
last_used=event.timestamp,
|
||||
)
|
||||
db.add(tool_usage)
|
||||
else:
|
||||
tool_usage.usage_count += 1
|
||||
tool_usage.last_used = event.timestamp
|
||||
|
||||
# Update statistics
|
||||
_update_statistics(db)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
return db_event
|
||||
|
||||
|
||||
def get_events(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
tool_name: Optional[str] = None,
|
||||
) -> Tuple[List[Event], int]:
|
||||
"""
|
||||
Get paginated list of events with optional filtering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
skip: Number of records to skip (offset)
|
||||
limit: Maximum number of records to return
|
||||
event_type: Filter by event type
|
||||
session_id: Filter by session ID
|
||||
tool_name: Filter by tool name
|
||||
|
||||
Returns:
|
||||
Tuple of (events list, total count)
|
||||
"""
|
||||
query = db.query(Event)
|
||||
|
||||
# Apply filters
|
||||
if event_type:
|
||||
# Support filtering by multiple event types (comma-separated)
|
||||
if "," in event_type:
|
||||
event_types = [et.strip() for et in event_type.split(",")]
|
||||
query = query.filter(Event.event_type.in_(event_types))
|
||||
else:
|
||||
query = query.filter(Event.event_type == event_type)
|
||||
|
||||
if session_id:
|
||||
query = query.filter(Event.session_id == session_id)
|
||||
|
||||
if tool_name:
|
||||
query = query.filter(Event.tool_name == tool_name)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Order by timestamp descending (newest first) and apply pagination
|
||||
events = query.order_by(desc(Event.timestamp)).offset(skip).limit(limit).all()
|
||||
|
||||
return events, total
|
||||
|
||||
|
||||
def get_event_by_id(db: Session, event_id: int) -> Optional[Event]:
|
||||
"""Get a single event by ID."""
|
||||
return db.query(Event).filter(Event.id == event_id).first()
|
||||
|
||||
|
||||
def delete_all_events(db: Session) -> None:
|
||||
"""Delete all events and reset statistics (for Reset Stats functionality)."""
|
||||
# Delete all events
|
||||
db.query(Event).delete()
|
||||
|
||||
# Delete all sessions
|
||||
db.query(SessionModel).delete()
|
||||
|
||||
# Delete all tool usage
|
||||
db.query(ToolUsage).delete()
|
||||
|
||||
# Reset statistics
|
||||
stats = db.query(Statistics).first()
|
||||
if stats:
|
||||
stats.total_events = 0
|
||||
stats.total_tools_used = 0
|
||||
stats.total_agents = 0
|
||||
stats.total_sessions = 0
|
||||
stats.last_updated = time.time()
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_statistics(db: Session) -> Statistics:
|
||||
"""Get current statistics."""
|
||||
stats = db.query(Statistics).first()
|
||||
if not stats:
|
||||
# Initialize if not exists
|
||||
stats = Statistics(
|
||||
id=1,
|
||||
total_events=0,
|
||||
total_tools_used=0,
|
||||
total_agents=0,
|
||||
total_sessions=0,
|
||||
last_updated=time.time(),
|
||||
)
|
||||
db.add(stats)
|
||||
db.commit()
|
||||
db.refresh(stats)
|
||||
return stats
|
||||
|
||||
|
||||
def get_tool_usage(db: Session) -> List[ToolUsage]:
|
||||
"""Get tool usage statistics."""
|
||||
return db.query(ToolUsage).order_by(desc(ToolUsage.usage_count)).all()
|
||||
|
||||
|
||||
def get_sessions(db: Session, limit: int = 100) -> List[SessionModel]:
|
||||
"""Get list of sessions."""
|
||||
return db.query(SessionModel).order_by(desc(SessionModel.started_at)).limit(limit).all()
|
||||
|
||||
|
||||
# Helper functions
|
||||
def _update_statistics(db: Session) -> None:
|
||||
"""Update cached statistics."""
|
||||
stats = db.query(Statistics).first()
|
||||
if not stats:
|
||||
stats = Statistics(id=1)
|
||||
db.add(stats)
|
||||
|
||||
# Count events
|
||||
stats.total_events = db.query(Event).count()
|
||||
|
||||
# Count unique tools
|
||||
stats.total_tools_used = db.query(ToolUsage).count()
|
||||
|
||||
# Count agents (SubagentStop events)
|
||||
stats.total_agents = db.query(Event).filter(Event.event_type == "SubagentStop").count()
|
||||
|
||||
# Count sessions
|
||||
stats.total_sessions = db.query(SessionModel).count()
|
||||
|
||||
# Update timestamp
|
||||
stats.last_updated = time.time()
|
||||
|
||||
|
||||
def _generate_description(event: EventCreate) -> str:
|
||||
"""Generate a human-readable description for an event."""
|
||||
if event.description:
|
||||
return event.description
|
||||
|
||||
# Generate description based on event type
|
||||
if event.event_type == "PreToolUse":
|
||||
if event.tool_name:
|
||||
return f"Preparing to use {event.tool_name}"
|
||||
return "Preparing to use tool"
|
||||
|
||||
elif event.event_type == "PostToolUse":
|
||||
if event.tool_name:
|
||||
status = "successfully" if event.success else "with errors"
|
||||
return f"Executed {event.tool_name} {status}"
|
||||
return "Tool execution completed"
|
||||
|
||||
elif event.event_type == "SessionStart":
|
||||
return "Session started"
|
||||
|
||||
elif event.event_type == "SessionEnd":
|
||||
return "Session ended"
|
||||
|
||||
elif event.event_type == "SubagentStop":
|
||||
return "Agent completed task"
|
||||
|
||||
elif event.event_type == "UserPromptSubmit":
|
||||
return "User submitted prompt"
|
||||
|
||||
else:
|
||||
return f"{event.event_type} event"
|
||||
132
backend/app/database.py
Normal file
132
backend/app/database.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Database configuration and models for Claude Code Monitor."""
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Float,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Index,
|
||||
CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.sql import func
|
||||
import os
|
||||
|
||||
# Database URL
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/claude_monitor.db")
|
||||
|
||||
# Create engine with WAL mode for better concurrency
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Event(Base):
|
||||
"""Event model for storing Claude Code events."""
|
||||
|
||||
__tablename__ = "events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
session_id = Column(String, nullable=False, index=True)
|
||||
event_type = Column(String, nullable=False, index=True) # PreToolUse, PostToolUse, etc.
|
||||
tool_name = Column(String, nullable=True, index=True) # Bash, Read, Write, etc.
|
||||
tool_input = Column(String, nullable=True) # JSON string
|
||||
tool_output = Column(String, nullable=True) # Tool response
|
||||
success = Column(Boolean, nullable=True) # Success/failure (NULL for PreToolUse)
|
||||
description = Column(String, nullable=True) # Human-readable description
|
||||
timestamp = Column(Float, nullable=False, index=True) # Unix timestamp
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_event_type_timestamp", "event_type", "timestamp"),
|
||||
Index("idx_session_timestamp", "session_id", "timestamp"),
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Session model for tracking Claude Code sessions."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
session_id = Column(String, primary_key=True)
|
||||
started_at = Column(Float, nullable=False, index=True)
|
||||
ended_at = Column(Float, nullable=True)
|
||||
event_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Statistics(Base):
|
||||
"""Statistics model for cached aggregations (single-row table)."""
|
||||
|
||||
__tablename__ = "statistics"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
total_events = Column(Integer, default=0)
|
||||
total_tools_used = Column(Integer, default=0)
|
||||
total_agents = Column(Integer, default=0)
|
||||
total_sessions = Column(Integer, default=0)
|
||||
last_updated = Column(Float, nullable=False)
|
||||
|
||||
__table_args__ = (CheckConstraint("id = 1", name="single_row"),)
|
||||
|
||||
|
||||
class ToolUsage(Base):
|
||||
"""Tool usage model for tracking unique tools."""
|
||||
|
||||
__tablename__ = "tool_usage"
|
||||
|
||||
tool_name = Column(String, primary_key=True)
|
||||
usage_count = Column(Integer, default=1)
|
||||
first_used = Column(Float, nullable=False)
|
||||
last_used = Column(Float, nullable=False, index=True)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database: create tables and enable WAL mode."""
|
||||
# Create all tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Enable WAL mode for SQLite
|
||||
if "sqlite" in DATABASE_URL:
|
||||
with engine.connect() as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.commit()
|
||||
|
||||
# Initialize statistics table with single row
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = db.query(Statistics).first()
|
||||
if not stats:
|
||||
stats = Statistics(
|
||||
id=1,
|
||||
total_events=0,
|
||||
total_tools_used=0,
|
||||
total_agents=0,
|
||||
total_sessions=0,
|
||||
last_updated=0.0,
|
||||
)
|
||||
db.add(stats)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for getting database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
90
backend/app/main.py
Normal file
90
backend/app/main.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Main FastAPI application for Claude Code Monitor."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import init_db
|
||||
from app.api import events, statistics, websocket_endpoint
|
||||
from app.schemas import HealthResponse
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler for startup and shutdown events."""
|
||||
# Startup
|
||||
logger.info("Starting Claude Code Monitor backend...")
|
||||
init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
logger.info("Backend ready to receive events")
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down Claude Code Monitor backend...")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Claude Code Monitor API",
|
||||
description="Real-time monitoring dashboard for Claude Code activity",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify exact origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(events.router)
|
||||
app.include_router(statistics.router)
|
||||
app.include_router(websocket_endpoint.router)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", response_model=HealthResponse, tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns the service status and current timestamp.
|
||||
"""
|
||||
return HealthResponse(status="healthy", timestamp=time.time())
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/", tags=["info"])
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"name": "Claude Code Monitor API",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"docs": "/docs",
|
||||
"websocket": "/ws",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
152
backend/app/schemas.py
Normal file
152
backend/app/schemas.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Pydantic schemas for API request/response validation."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Event Schemas
|
||||
class EventCreate(BaseModel):
|
||||
"""Schema for creating a new event."""
|
||||
|
||||
session_id: str = Field(..., description="Claude Code session ID")
|
||||
event_type: str = Field(
|
||||
...,
|
||||
description="Event type: PreToolUse, PostToolUse, SessionStart, SessionEnd, SubagentStop, UserPromptSubmit",
|
||||
)
|
||||
tool_name: Optional[str] = Field(None, description="Tool name (Bash, Read, Write, etc.)")
|
||||
tool_input: Optional[Any] = Field(None, description="Tool input parameters (JSON)")
|
||||
tool_output: Optional[str] = Field(None, description="Tool output/response")
|
||||
success: Optional[bool] = Field(None, description="Success/failure status")
|
||||
description: Optional[str] = Field(None, description="Human-readable description")
|
||||
timestamp: float = Field(..., description="Unix timestamp with milliseconds")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"session_id": "abc-123-def",
|
||||
"event_type": "PostToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "ls -la"},
|
||||
"tool_output": "total 24\ndrwxr-xr-x...",
|
||||
"success": True,
|
||||
"timestamp": 1734251599.887,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
"""Schema for event response."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
event_type: str
|
||||
tool_name: Optional[str] = None
|
||||
tool_input: Optional[Any] = None
|
||||
tool_output: Optional[str] = None
|
||||
success: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
timestamp: float
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EventListResponse(BaseModel):
|
||||
"""Schema for paginated event list response."""
|
||||
|
||||
events: List[EventResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
# Statistics Schemas
|
||||
class StatisticsResponse(BaseModel):
|
||||
"""Schema for statistics response."""
|
||||
|
||||
total_events: int = Field(..., description="Total number of events")
|
||||
total_tools_used: int = Field(..., description="Number of unique tools used")
|
||||
total_agents: int = Field(..., description="Number of agents spawned")
|
||||
total_sessions: int = Field(..., description="Number of unique sessions")
|
||||
last_updated: float = Field(..., description="Last update timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"total_events": 66,
|
||||
"total_tools_used": 3,
|
||||
"total_agents": 3,
|
||||
"total_sessions": 1,
|
||||
"last_updated": 1734251599.887,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ToolUsageResponse(BaseModel):
|
||||
"""Schema for tool usage statistics."""
|
||||
|
||||
tool_name: str
|
||||
usage_count: int
|
||||
first_used: float
|
||||
last_used: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Schema for session response."""
|
||||
|
||||
session_id: str
|
||||
started_at: float
|
||||
ended_at: Optional[float] = None
|
||||
event_count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# WebSocket Message Schemas
|
||||
class WebSocketMessage(BaseModel):
|
||||
"""Schema for WebSocket messages."""
|
||||
|
||||
type: str = Field(..., description="Message type: event_created, stats_updated, connection_status, stats_reset")
|
||||
event: Optional[EventResponse] = Field(None, description="Event data (for event_created)")
|
||||
statistics: Optional[StatisticsResponse] = Field(None, description="Statistics data")
|
||||
message: Optional[str] = Field(None, description="Optional message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"type": "event_created",
|
||||
"event": {
|
||||
"id": 123,
|
||||
"session_id": "abc-123",
|
||||
"event_type": "PostToolUse",
|
||||
"tool_name": "Bash",
|
||||
"timestamp": 1734251599.887,
|
||||
},
|
||||
"statistics": {
|
||||
"total_events": 67,
|
||||
"total_tools_used": 3,
|
||||
"total_agents": 3,
|
||||
"total_sessions": 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Health Check Schema
|
||||
class HealthResponse(BaseModel):
|
||||
"""Schema for health check response."""
|
||||
|
||||
status: str = Field(..., description="Service status")
|
||||
timestamp: float = Field(..., description="Current timestamp")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {"example": {"status": "healthy", "timestamp": 1734251599.887}}
|
||||
89
backend/app/websocket.py
Normal file
89
backend/app/websocket.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""WebSocket connection manager for real-time updates."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from typing import List, Dict, Any
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections for broadcasting events."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""Accept a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
logger.info(f"New WebSocket connection. Total connections: {len(self.active_connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
"""Remove a WebSocket connection."""
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
async def send_personal_message(self, message: Dict[str, Any], websocket: WebSocket):
|
||||
"""Send a message to a specific connection."""
|
||||
try:
|
||||
await websocket.send_text(json.dumps(message))
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending personal message: {e}")
|
||||
|
||||
async def broadcast(self, message: Dict[str, Any]):
|
||||
"""Broadcast a message to all connected clients."""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
# Convert message to JSON string
|
||||
message_str = json.dumps(message)
|
||||
|
||||
# Send to all connections
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_text(message_str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting to connection: {e}")
|
||||
disconnected.append(connection)
|
||||
|
||||
# Clean up disconnected connections
|
||||
for connection in disconnected:
|
||||
self.disconnect(connection)
|
||||
|
||||
async def broadcast_event(self, event_data: Dict[str, Any], statistics_data: Dict[str, Any]):
|
||||
"""Broadcast a new event with updated statistics."""
|
||||
message = {
|
||||
"type": "event_created",
|
||||
"event": event_data,
|
||||
"statistics": statistics_data,
|
||||
}
|
||||
await self.broadcast(message)
|
||||
|
||||
async def broadcast_stats_reset(self):
|
||||
"""Broadcast a stats reset notification."""
|
||||
message = {
|
||||
"type": "stats_reset",
|
||||
"message": "Statistics have been reset",
|
||||
}
|
||||
await self.broadcast(message)
|
||||
|
||||
async def broadcast_stats_update(self, statistics_data: Dict[str, Any]):
|
||||
"""Broadcast updated statistics."""
|
||||
message = {
|
||||
"type": "stats_updated",
|
||||
"statistics": statistics_data,
|
||||
}
|
||||
await self.broadcast(message)
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get the number of active connections."""
|
||||
return len(self.active_connections)
|
||||
|
||||
|
||||
# Global connection manager instance
|
||||
manager = ConnectionManager()
|
||||
Reference in New Issue
Block a user