Fix project isolation: Make loadChatHistory respect active project sessions

- Modified loadChatHistory() to check for active project before fetching all sessions
- When active project exists, use project.sessions instead of fetching from API
- Added detailed console logging to debug session filtering
- This prevents ALL sessions from appearing in every project's sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-22 14:43:05 +00:00
Unverified
parent b82837aa5f
commit 55aafbae9a
6463 changed files with 1115462 additions and 4486 deletions

View File

@@ -0,0 +1,8 @@
# ABOUTME: Web UI module for Ralph Orchestrator monitoring and control
# ABOUTME: Provides real-time dashboard for agent execution and system metrics
"""Web UI module for Ralph Orchestrator monitoring."""
from .server import WebMonitor
__all__ = ['WebMonitor']

View File

@@ -0,0 +1,74 @@
# ABOUTME: Entry point for running the Ralph Orchestrator web monitoring server
# ABOUTME: Enables execution with `python -m ralph_orchestrator.web`
"""Entry point for the Ralph Orchestrator web monitoring server."""
import argparse
import asyncio
import logging
from .server import WebMonitor
logger = logging.getLogger(__name__)
def main():
"""Main entry point for the web monitoring server."""
parser = argparse.ArgumentParser(
description="Ralph Orchestrator Web Monitoring Dashboard"
)
parser.add_argument(
"--port",
type=int,
default=8080,
help="Port to run the web server on (default: 8080)"
)
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host to bind the server to (default: 0.0.0.0)"
)
parser.add_argument(
"--no-auth",
action="store_true",
help="Disable authentication (not recommended for production)"
)
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set logging level (default: INFO)"
)
args = parser.parse_args()
# Configure logging
logging.basicConfig(
level=getattr(logging, args.log_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Create and run the web monitor
monitor = WebMonitor(
port=args.port,
host=args.host,
enable_auth=not args.no_auth
)
logger.info(f"Starting Ralph Orchestrator Web Monitor on {args.host}:{args.port}")
if args.no_auth:
logger.warning("Authentication is disabled - not recommended for production")
else:
logger.info("Authentication enabled - default credentials: admin / ralph-admin-2024")
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
logger.info("Web monitor stopped by user")
except Exception as e:
logger.error(f"Web monitor error: {e}", exc_info=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,186 @@
# ABOUTME: Authentication module for Ralph Orchestrator web monitoring dashboard
# ABOUTME: Provides JWT-based authentication with username/password login
"""Authentication module for the web monitoring server."""
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import jwt
from passlib.context import CryptContext
from fastapi import HTTPException, Security, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
# Configuration
SECRET_KEY = os.getenv("RALPH_WEB_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("RALPH_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours default
# Default admin credentials (should be changed in production)
DEFAULT_USERNAME = os.getenv("RALPH_WEB_USERNAME", "admin")
DEFAULT_PASSWORD_HASH = os.getenv("RALPH_WEB_PASSWORD_HASH", None)
# If no password hash is provided, generate one for the default password
if not DEFAULT_PASSWORD_HASH:
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
default_password = os.getenv("RALPH_WEB_PASSWORD", "admin123")
DEFAULT_PASSWORD_HASH = pwd_context.hash(default_password)
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# HTTP Bearer token authentication
security = HTTPBearer()
class LoginRequest(BaseModel):
"""Login request model."""
username: str
password: str
class TokenResponse(BaseModel):
"""Token response model."""
access_token: str
token_type: str = "bearer"
expires_in: int
class AuthManager:
"""Manages authentication for the web server."""
def __init__(self):
self.pwd_context = pwd_context
self.secret_key = SECRET_KEY
self.algorithm = ALGORITHM
self.access_token_expire_minutes = ACCESS_TOKEN_EXPIRE_MINUTES
# Simple in-memory user store (can be extended to use a database)
self.users = {
DEFAULT_USERNAME: {
"username": DEFAULT_USERNAME,
"hashed_password": DEFAULT_PASSWORD_HASH,
"is_active": True,
"is_admin": True
}
}
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return self.pwd_context.verify(plain_password, hashed_password)
def get_password_hash(self, password: str) -> str:
"""Hash a password."""
return self.pwd_context.hash(password)
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate a user by username and password."""
user = self.users.get(username)
if not user:
return None
if not self.verify_password(password, user["hashed_password"]):
return None
if not user.get("is_active", True):
return None
return user
def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=self.access_token_expire_minutes)
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def verify_token(self, token: str) -> Dict[str, Any]:
"""Verify and decode a JWT token."""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user still exists and is active
user = self.users.get(username)
if not user or not user.get("is_active", True):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return {"username": username, "user": user}
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
) from None
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
) from None
def add_user(self, username: str, password: str, is_admin: bool = False) -> bool:
"""Add a new user to the system."""
if username in self.users:
return False
self.users[username] = {
"username": username,
"hashed_password": self.get_password_hash(password),
"is_active": True,
"is_admin": is_admin
}
return True
def remove_user(self, username: str) -> bool:
"""Remove a user from the system."""
if username in self.users and username != DEFAULT_USERNAME:
del self.users[username]
return True
return False
def update_password(self, username: str, new_password: str) -> bool:
"""Update a user's password."""
if username not in self.users:
return False
self.users[username]["hashed_password"] = self.get_password_hash(new_password)
return True
# Global auth manager instance
auth_manager = AuthManager()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)) -> Dict[str, Any]:
"""Get the current authenticated user from the token."""
token = credentials.credentials
user_data = auth_manager.verify_token(token)
return user_data
async def require_admin(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
"""Require the current user to be an admin."""
if not current_user["user"].get("is_admin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user

View File

@@ -0,0 +1,467 @@
# ABOUTME: Database module for Ralph Orchestrator web monitoring
# ABOUTME: Provides SQLite storage for execution history and metrics
import sqlite3
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from contextlib import contextmanager
import threading
logger = logging.getLogger(__name__)
class DatabaseManager:
"""Manages SQLite database for Ralph Orchestrator execution history."""
def __init__(self, db_path: Optional[Path] = None):
"""Initialize database manager.
Args:
db_path: Path to SQLite database file (default: ~/.ralph/history.db)
"""
if db_path is None:
config_dir = Path.home() / ".ralph"
config_dir.mkdir(exist_ok=True)
db_path = config_dir / "history.db"
self.db_path = db_path
self._lock = threading.Lock()
self._init_database()
logger.info(f"Database initialized at {self.db_path}")
@contextmanager
def _get_connection(self):
"""Thread-safe context manager for database connections."""
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row # Enable column access by name
try:
yield conn
finally:
conn.close()
def _init_database(self):
"""Initialize database schema."""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
# Create orchestrator_runs table
cursor.execute("""
CREATE TABLE IF NOT EXISTS orchestrator_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
orchestrator_id TEXT NOT NULL,
prompt_path TEXT NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
status TEXT NOT NULL,
total_iterations INTEGER DEFAULT 0,
max_iterations INTEGER,
error_message TEXT,
metadata TEXT
)
""")
# Create iteration_history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS iteration_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
iteration_number INTEGER NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
status TEXT NOT NULL,
current_task TEXT,
agent_output TEXT,
error_message TEXT,
metrics TEXT,
FOREIGN KEY (run_id) REFERENCES orchestrator_runs(id)
)
""")
# Create task_history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
task_description TEXT NOT NULL,
status TEXT NOT NULL,
start_time TIMESTAMP,
end_time TIMESTAMP,
iteration_count INTEGER DEFAULT 0,
error_message TEXT,
FOREIGN KEY (run_id) REFERENCES orchestrator_runs(id)
)
""")
# Create indices for better query performance
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_runs_orchestrator_id
ON orchestrator_runs(orchestrator_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_runs_start_time
ON orchestrator_runs(start_time DESC)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_iterations_run_id
ON iteration_history(run_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_tasks_run_id
ON task_history(run_id)
""")
conn.commit()
def create_run(self, orchestrator_id: str, prompt_path: str,
max_iterations: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None) -> int:
"""Create a new orchestrator run entry.
Args:
orchestrator_id: Unique ID of the orchestrator
prompt_path: Path to the prompt file
max_iterations: Maximum iterations for this run
metadata: Additional metadata to store
Returns:
ID of the created run
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO orchestrator_runs
(orchestrator_id, prompt_path, start_time, status, max_iterations, metadata)
VALUES (?, ?, ?, ?, ?, ?)
""", (
orchestrator_id,
prompt_path,
datetime.now().isoformat(),
"running",
max_iterations,
json.dumps(metadata) if metadata else None
))
conn.commit()
return cursor.lastrowid
def update_run_status(self, run_id: int, status: str,
error_message: Optional[str] = None,
total_iterations: Optional[int] = None):
"""Update the status of an orchestrator run.
Args:
run_id: ID of the run to update
status: New status (running, completed, failed, paused)
error_message: Error message if failed
total_iterations: Total iterations completed
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
updates = ["status = ?"]
params = [status]
if status in ["completed", "failed"]:
updates.append("end_time = ?")
params.append(datetime.now().isoformat())
if error_message is not None:
updates.append("error_message = ?")
params.append(error_message)
if total_iterations is not None:
updates.append("total_iterations = ?")
params.append(total_iterations)
params.append(run_id)
cursor.execute(f"""
UPDATE orchestrator_runs
SET {', '.join(updates)}
WHERE id = ?
""", params)
conn.commit()
def add_iteration(self, run_id: int, iteration_number: int,
current_task: Optional[str] = None,
metrics: Optional[Dict[str, Any]] = None) -> int:
"""Add a new iteration entry.
Args:
run_id: ID of the parent run
iteration_number: Iteration number
current_task: Current task being executed
metrics: Performance metrics for this iteration
Returns:
ID of the created iteration
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO iteration_history
(run_id, iteration_number, start_time, status, current_task, metrics)
VALUES (?, ?, ?, ?, ?, ?)
""", (
run_id,
iteration_number,
datetime.now().isoformat(),
"running",
current_task,
json.dumps(metrics) if metrics else None
))
conn.commit()
return cursor.lastrowid
def update_iteration(self, iteration_id: int, status: str,
agent_output: Optional[str] = None,
error_message: Optional[str] = None):
"""Update an iteration entry.
Args:
iteration_id: ID of the iteration to update
status: New status (running, completed, failed)
agent_output: Output from the agent
error_message: Error message if failed
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE iteration_history
SET status = ?, end_time = ?, agent_output = ?, error_message = ?
WHERE id = ?
""", (
status,
datetime.now().isoformat() if status != "running" else None,
agent_output,
error_message,
iteration_id
))
conn.commit()
def add_task(self, run_id: int, task_description: str) -> int:
"""Add a task entry.
Args:
run_id: ID of the parent run
task_description: Description of the task
Returns:
ID of the created task
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO task_history (run_id, task_description, status)
VALUES (?, ?, ?)
""", (run_id, task_description, "pending"))
conn.commit()
return cursor.lastrowid
def update_task_status(self, task_id: int, status: str,
error_message: Optional[str] = None):
"""Update task status.
Args:
task_id: ID of the task to update
status: New status (pending, in_progress, completed, failed)
error_message: Error message if failed
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
if status == "in_progress":
cursor.execute("""
UPDATE task_history
SET status = ?, start_time = ?
WHERE id = ?
""", (status, now, task_id))
elif status in ["completed", "failed"]:
cursor.execute("""
UPDATE task_history
SET status = ?, end_time = ?, error_message = ?
WHERE id = ?
""", (status, now, error_message, task_id))
else:
cursor.execute("""
UPDATE task_history
SET status = ?
WHERE id = ?
""", (status, task_id))
conn.commit()
def get_recent_runs(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent orchestrator runs.
Args:
limit: Maximum number of runs to return
Returns:
List of run dictionaries
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM orchestrator_runs
ORDER BY start_time DESC
LIMIT ?
""", (limit,))
runs = []
for row in cursor.fetchall():
run = dict(row)
if run.get('metadata'):
run['metadata'] = json.loads(run['metadata'])
runs.append(run)
return runs
def get_run_details(self, run_id: int) -> Optional[Dict[str, Any]]:
"""Get detailed information about a specific run.
Args:
run_id: ID of the run
Returns:
Run details with iterations and tasks
"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Get run info
cursor.execute("SELECT * FROM orchestrator_runs WHERE id = ?", (run_id,))
row = cursor.fetchone()
if not row:
return None
run = dict(row)
if run.get('metadata'):
run['metadata'] = json.loads(run['metadata'])
# Get iterations
cursor.execute("""
SELECT * FROM iteration_history
WHERE run_id = ?
ORDER BY iteration_number
""", (run_id,))
iterations = []
for row in cursor.fetchall():
iteration = dict(row)
if iteration.get('metrics'):
iteration['metrics'] = json.loads(iteration['metrics'])
iterations.append(iteration)
run['iterations'] = iterations
# Get tasks
cursor.execute("""
SELECT * FROM task_history
WHERE run_id = ?
ORDER BY id
""", (run_id,))
run['tasks'] = [dict(row) for row in cursor.fetchall()]
return run
def get_statistics(self) -> Dict[str, Any]:
"""Get database statistics.
Returns:
Dictionary with statistics
"""
with self._get_connection() as conn:
cursor = conn.cursor()
stats = {}
# Total runs
cursor.execute("SELECT COUNT(*) FROM orchestrator_runs")
stats['total_runs'] = cursor.fetchone()[0]
# Runs by status
cursor.execute("""
SELECT status, COUNT(*)
FROM orchestrator_runs
GROUP BY status
""")
stats['runs_by_status'] = dict(cursor.fetchall())
# Total iterations
cursor.execute("SELECT COUNT(*) FROM iteration_history")
stats['total_iterations'] = cursor.fetchone()[0]
# Total tasks
cursor.execute("SELECT COUNT(*) FROM task_history")
stats['total_tasks'] = cursor.fetchone()[0]
# Average iterations per run
cursor.execute("""
SELECT AVG(total_iterations)
FROM orchestrator_runs
WHERE total_iterations > 0
""")
result = cursor.fetchone()[0]
stats['avg_iterations_per_run'] = round(result, 2) if result else 0
# Success rate
cursor.execute("""
SELECT
COUNT(CASE WHEN status = 'completed' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0)
FROM orchestrator_runs
WHERE status IN ('completed', 'failed')
""")
result = cursor.fetchone()[0]
stats['success_rate'] = round(result, 2) if result else 0
return stats
def cleanup_old_records(self, days: int = 30):
"""Remove records older than specified days.
Args:
days: Number of days to keep
"""
with self._lock:
with self._get_connection() as conn:
cursor = conn.cursor()
# Get run IDs to delete (using SQLite datetime functions directly)
cursor.execute("""
SELECT id FROM orchestrator_runs
WHERE datetime(start_time) < datetime('now', '-' || ? || ' days')
""", (days,))
run_ids = [row[0] for row in cursor.fetchall()]
if run_ids:
# Delete iterations
cursor.execute("""
DELETE FROM iteration_history
WHERE run_id IN ({})
""".format(','.join('?' * len(run_ids))), run_ids)
# Delete tasks
cursor.execute("""
DELETE FROM task_history
WHERE run_id IN ({})
""".format(','.join('?' * len(run_ids))), run_ids)
# Delete runs
cursor.execute("""
DELETE FROM orchestrator_runs
WHERE id IN ({})
""".format(','.join('?' * len(run_ids))), run_ids)
conn.commit()
logger.info(f"Cleaned up {len(run_ids)} old runs")

View File

@@ -0,0 +1,280 @@
# ABOUTME: Implements rate limiting for API endpoints to prevent abuse
# ABOUTME: Uses token bucket algorithm with configurable limits per endpoint
"""Rate limiting implementation for the Ralph web server."""
import asyncio
import time
from collections import defaultdict
from typing import Dict, Optional, Tuple
from functools import wraps
import logging
from fastapi import Request, status
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
class RateLimiter:
"""Token bucket rate limiter implementation.
Uses a token bucket algorithm to limit requests per IP address.
Tokens are replenished at a fixed rate up to a maximum capacity.
"""
def __init__(
self,
capacity: int = 100,
refill_rate: float = 10.0,
refill_period: float = 1.0,
block_duration: float = 60.0
):
"""Initialize the rate limiter.
Args:
capacity: Maximum number of tokens in the bucket
refill_rate: Number of tokens to add per refill period
refill_period: Time in seconds between refills
block_duration: Time in seconds to block an IP after exhausting tokens
"""
self.capacity = capacity
self.refill_rate = refill_rate
self.refill_period = refill_period
self.block_duration = block_duration
# Store buckets per IP address
self.buckets: Dict[str, Tuple[float, float, float]] = defaultdict(
lambda: (float(capacity), time.time(), 0.0)
)
self.blocked_ips: Dict[str, float] = {}
# Lock for thread-safe access
self._lock = asyncio.Lock()
async def check_rate_limit(self, identifier: str) -> Tuple[bool, Optional[int]]:
"""Check if a request is allowed under the rate limit.
Args:
identifier: Unique identifier for the client (e.g., IP address)
Returns:
Tuple of (allowed, retry_after_seconds)
"""
async with self._lock:
current_time = time.time()
# Check if IP is blocked
if identifier in self.blocked_ips:
block_end = self.blocked_ips[identifier]
if current_time < block_end:
retry_after = int(block_end - current_time)
return False, retry_after
else:
# Unblock the IP
del self.blocked_ips[identifier]
# Get or create bucket for this identifier
tokens, last_refill, consecutive_violations = self.buckets[identifier]
# Calculate tokens to add based on time elapsed
time_elapsed = current_time - last_refill
tokens_to_add = (time_elapsed / self.refill_period) * self.refill_rate
tokens = min(self.capacity, tokens + tokens_to_add)
if tokens >= 1:
# Consume a token
tokens -= 1
consecutive_violations = 0
self.buckets[identifier] = (tokens, current_time, consecutive_violations)
return True, None
else:
# No tokens available
consecutive_violations += 1
# Block IP if too many consecutive violations
if consecutive_violations >= 5:
block_end = current_time + self.block_duration
self.blocked_ips[identifier] = block_end
del self.buckets[identifier]
return False, int(self.block_duration)
self.buckets[identifier] = (tokens, current_time, consecutive_violations)
retry_after = int(self.refill_period / self.refill_rate)
return False, retry_after
async def cleanup_old_buckets(self, max_age: float = 3600.0):
"""Remove old inactive buckets to prevent memory growth.
Args:
max_age: Maximum age in seconds for inactive buckets
"""
async with self._lock:
current_time = time.time()
to_remove = []
for identifier, (_tokens, last_refill, _) in self.buckets.items():
if current_time - last_refill > max_age:
to_remove.append(identifier)
for identifier in to_remove:
del self.buckets[identifier]
# Clean up expired blocks
to_remove = []
for identifier, block_end in self.blocked_ips.items():
if current_time >= block_end:
to_remove.append(identifier)
for identifier in to_remove:
del self.blocked_ips[identifier]
if to_remove:
logger.info(f"Cleaned up {len(to_remove)} expired rate limit entries")
class RateLimitConfig:
"""Configuration for different rate limit tiers."""
# Default limits for different endpoint categories
LIMITS = {
"auth": {"capacity": 10, "refill_rate": 1.0, "refill_period": 60.0}, # 10 requests/minute
"api": {"capacity": 100, "refill_rate": 10.0, "refill_period": 1.0}, # 100 requests/10 seconds
"websocket": {"capacity": 10, "refill_rate": 1.0, "refill_period": 10.0}, # 10 connections/10 seconds
"static": {"capacity": 200, "refill_rate": 20.0, "refill_period": 1.0}, # 200 requests/20 seconds
"admin": {"capacity": 50, "refill_rate": 5.0, "refill_period": 1.0}, # 50 requests/5 seconds
}
@classmethod
def get_limiter(cls, category: str) -> RateLimiter:
"""Get or create a rate limiter for a specific category.
Args:
category: The category of endpoints to limit
Returns:
A configured RateLimiter instance
"""
if not hasattr(cls, "_limiters"):
cls._limiters = {}
if category not in cls._limiters:
config = cls.LIMITS.get(category, cls.LIMITS["api"])
cls._limiters[category] = RateLimiter(**config)
return cls._limiters[category]
def rate_limit(category: str = "api"):
"""Decorator to apply rate limiting to FastAPI endpoints.
Args:
category: The rate limit category to apply
"""
def decorator(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
# Get client IP address
client_ip = request.client.host if request.client else "unknown"
# Check for X-Forwarded-For header (for proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
# Get the appropriate rate limiter
limiter = RateLimitConfig.get_limiter(category)
# Check rate limit
allowed, retry_after = await limiter.check_rate_limit(client_ip)
if not allowed:
logger.warning(f"Rate limit exceeded for {client_ip} on {category} endpoint")
response = JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"detail": "Rate limit exceeded",
"retry_after": retry_after
}
)
if retry_after:
response.headers["Retry-After"] = str(retry_after)
return response
# Call the original function
return await func(request, *args, **kwargs)
return wrapper
return decorator
async def setup_rate_limit_cleanup():
"""Set up periodic cleanup of old rate limit buckets.
Returns:
The cleanup task
"""
async def cleanup_task():
while True:
try:
await asyncio.sleep(300) # Run every 5 minutes
for category in RateLimitConfig.LIMITS:
limiter = RateLimitConfig.get_limiter(category)
await limiter.cleanup_old_buckets()
except Exception as e:
logger.error(f"Error in rate limit cleanup: {e}")
return asyncio.create_task(cleanup_task())
# Middleware for global rate limiting
async def rate_limit_middleware(request: Request, call_next):
"""Global rate limiting middleware for all requests.
Args:
request: The incoming request
call_next: The next middleware or endpoint
Returns:
The response
"""
# Determine the category based on the path
path = request.url.path
if path.startswith("/api/auth"):
category = "auth"
elif path.startswith("/api/admin"):
category = "admin"
elif path.startswith("/ws"):
category = "websocket"
elif path.startswith("/static"):
category = "static"
else:
category = "api"
# Get client IP
client_ip = request.client.host if request.client else "unknown"
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
# Check rate limit
limiter = RateLimitConfig.get_limiter(category)
allowed, retry_after = await limiter.check_rate_limit(client_ip)
if not allowed:
logger.warning(f"Rate limit exceeded for {client_ip} on {path}")
response = JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"detail": "Rate limit exceeded",
"retry_after": retry_after
}
)
if retry_after:
response.headers["Retry-After"] = str(retry_after)
return response
# Continue with the request
response = await call_next(request)
return response

View File

@@ -0,0 +1,712 @@
# ABOUTME: FastAPI web server for Ralph Orchestrator monitoring dashboard
# ABOUTME: Provides REST API endpoints and WebSocket connections for real-time updates
"""FastAPI web server for Ralph Orchestrator monitoring."""
import json
import time
import asyncio
import logging
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any, List
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, status
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
import psutil
from ..orchestrator import RalphOrchestrator
from .auth import (
auth_manager, LoginRequest, TokenResponse,
get_current_user, require_admin
)
from .database import DatabaseManager
from .rate_limit import rate_limit_middleware, setup_rate_limit_cleanup
logger = logging.getLogger(__name__)
class PromptUpdateRequest(BaseModel):
"""Request model for updating orchestrator prompt."""
content: str
class OrchestratorMonitor:
"""Monitors and manages orchestrator instances."""
def __init__(self):
self.active_orchestrators: Dict[str, RalphOrchestrator] = {}
self.execution_history: List[Dict[str, Any]] = []
self.websocket_clients: List[WebSocket] = []
self.metrics_cache: Dict[str, Any] = {}
self.system_metrics_task: Optional[asyncio.Task] = None
self.database = DatabaseManager()
self.active_runs: Dict[str, int] = {} # Maps orchestrator_id to run_id
self.active_iterations: Dict[str, int] = {} # Maps orchestrator_id to iteration_id
async def start_monitoring(self):
"""Start background monitoring tasks."""
if not self.system_metrics_task:
self.system_metrics_task = asyncio.create_task(self._monitor_system_metrics())
async def stop_monitoring(self):
"""Stop background monitoring tasks."""
if self.system_metrics_task:
self.system_metrics_task.cancel()
try:
await self.system_metrics_task
except asyncio.CancelledError:
pass
async def _monitor_system_metrics(self):
"""Monitor system metrics continuously."""
while True:
try:
# Collect system metrics
metrics = {
"timestamp": datetime.now().isoformat(),
"cpu_percent": psutil.cpu_percent(interval=1),
"memory": {
"total": psutil.virtual_memory().total,
"available": psutil.virtual_memory().available,
"percent": psutil.virtual_memory().percent
},
"active_processes": len(psutil.pids()),
"orchestrators": len(self.active_orchestrators)
}
self.metrics_cache["system"] = metrics
# Broadcast to WebSocket clients
await self._broadcast_to_clients({
"type": "system_metrics",
"data": metrics
})
await asyncio.sleep(5) # Update every 5 seconds
except Exception as e:
logger.error(f"Error monitoring system metrics: {e}")
await asyncio.sleep(5)
async def _broadcast_to_clients(self, message: Dict[str, Any]):
"""Broadcast message to all connected WebSocket clients."""
disconnected_clients = []
for client in self.websocket_clients:
try:
await client.send_json(message)
except Exception:
disconnected_clients.append(client)
# Remove disconnected clients
for client in disconnected_clients:
if client in self.websocket_clients:
self.websocket_clients.remove(client)
def _schedule_broadcast(self, message: Dict[str, Any]):
"""Schedule a broadcast to clients, handling both sync and async contexts."""
try:
# Check if there's a running event loop (raises RuntimeError if not)
asyncio.get_running_loop()
# If we're in an async context, schedule the broadcast
asyncio.create_task(self._broadcast_to_clients(message))
except RuntimeError:
# No event loop running - we're in a sync context (e.g., during testing)
# The broadcast will be skipped in this case
pass
async def broadcast_update(self, message: Dict[str, Any]):
"""Public method to broadcast updates to WebSocket clients."""
await self._broadcast_to_clients(message)
def register_orchestrator(self, orchestrator_id: str, orchestrator: RalphOrchestrator):
"""Register an orchestrator instance."""
self.active_orchestrators[orchestrator_id] = orchestrator
# Create a new run in the database
try:
run_id = self.database.create_run(
orchestrator_id=orchestrator_id,
prompt_path=str(orchestrator.prompt_file),
max_iterations=orchestrator.max_iterations,
metadata={
"primary_tool": orchestrator.primary_tool,
"max_runtime": orchestrator.max_runtime
}
)
self.active_runs[orchestrator_id] = run_id
# Extract and store tasks if available
if hasattr(orchestrator, 'task_queue'):
for task in orchestrator.task_queue:
self.database.add_task(run_id, task['description'])
except Exception as e:
logger.error(f"Error creating database run for orchestrator {orchestrator_id}: {e}")
self._schedule_broadcast({
"type": "orchestrator_registered",
"data": {"id": orchestrator_id, "timestamp": datetime.now().isoformat()}
})
def unregister_orchestrator(self, orchestrator_id: str):
"""Unregister an orchestrator instance."""
if orchestrator_id in self.active_orchestrators:
# Update database run status
if orchestrator_id in self.active_runs:
try:
orchestrator = self.active_orchestrators[orchestrator_id]
status = "completed" if not orchestrator.stop_requested else "stopped"
total_iterations = orchestrator.metrics.total_iterations if hasattr(orchestrator, 'metrics') else 0
self.database.update_run_status(
self.active_runs[orchestrator_id],
status=status,
total_iterations=total_iterations
)
del self.active_runs[orchestrator_id]
except Exception as e:
logger.error(f"Error updating database run for orchestrator {orchestrator_id}: {e}")
# Remove from active orchestrators
del self.active_orchestrators[orchestrator_id]
# Remove active iteration tracking if exists
if orchestrator_id in self.active_iterations:
del self.active_iterations[orchestrator_id]
self._schedule_broadcast({
"type": "orchestrator_unregistered",
"data": {"id": orchestrator_id, "timestamp": datetime.now().isoformat()}
})
def get_orchestrator_status(self, orchestrator_id: str) -> Dict[str, Any]:
"""Get status of a specific orchestrator."""
if orchestrator_id not in self.active_orchestrators:
return None
orchestrator = self.active_orchestrators[orchestrator_id]
# Try to use the new get_orchestrator_state method if it exists
if hasattr(orchestrator, 'get_orchestrator_state'):
state = orchestrator.get_orchestrator_state()
state['id'] = orchestrator_id # Override with our ID
return state
else:
# Fallback to old method for compatibility
return {
"id": orchestrator_id,
"status": "running" if not orchestrator.stop_requested else "stopping",
"metrics": orchestrator.metrics.to_dict(),
"cost": orchestrator.cost_tracker.get_summary() if orchestrator.cost_tracker else None,
"config": {
"primary_tool": orchestrator.primary_tool,
"max_iterations": orchestrator.max_iterations,
"max_runtime": orchestrator.max_runtime,
"prompt_file": str(orchestrator.prompt_file)
}
}
def get_all_orchestrators_status(self) -> List[Dict[str, Any]]:
"""Get status of all orchestrators."""
return [
self.get_orchestrator_status(orch_id)
for orch_id in self.active_orchestrators
]
def start_iteration(self, orchestrator_id: str, iteration_number: int,
current_task: Optional[str] = None) -> Optional[int]:
"""Start tracking a new iteration.
Args:
orchestrator_id: ID of the orchestrator
iteration_number: Iteration number
current_task: Current task being executed
Returns:
Iteration ID if successful, None otherwise
"""
if orchestrator_id not in self.active_runs:
return None
try:
iteration_id = self.database.add_iteration(
run_id=self.active_runs[orchestrator_id],
iteration_number=iteration_number,
current_task=current_task,
metrics=None # Can be enhanced to include metrics
)
self.active_iterations[orchestrator_id] = iteration_id
return iteration_id
except Exception as e:
logger.error(f"Error starting iteration for orchestrator {orchestrator_id}: {e}")
return None
def end_iteration(self, orchestrator_id: str, status: str = "completed",
agent_output: Optional[str] = None, error_message: Optional[str] = None):
"""End tracking for the current iteration.
Args:
orchestrator_id: ID of the orchestrator
status: Status of the iteration (completed, failed)
agent_output: Output from the agent
error_message: Error message if failed
"""
if orchestrator_id not in self.active_iterations:
return
try:
self.database.update_iteration(
iteration_id=self.active_iterations[orchestrator_id],
status=status,
agent_output=agent_output,
error_message=error_message
)
del self.active_iterations[orchestrator_id]
except Exception as e:
logger.error(f"Error ending iteration for orchestrator {orchestrator_id}: {e}")
class WebMonitor:
"""Web monitoring server for Ralph Orchestrator."""
def __init__(self, host: str = "0.0.0.0", port: int = 8080, enable_auth: bool = True):
self.host = host
self.port = port
self.enable_auth = enable_auth
self.monitor = OrchestratorMonitor()
self.app = None
self._setup_app()
def _setup_app(self):
"""Setup FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await self.monitor.start_monitoring()
# Start rate limit cleanup task
cleanup_task = await setup_rate_limit_cleanup()
yield
# Shutdown
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
await self.monitor.stop_monitoring()
self.app = FastAPI(
title="Ralph Orchestrator Monitor",
description="Real-time monitoring for Ralph AI Orchestrator",
version="1.0.0",
lifespan=lifespan
)
# Add CORS middleware
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add rate limiting middleware
self.app.middleware("http")(rate_limit_middleware)
# Mount static files directory if it exists
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Setup routes
self._setup_routes()
def _setup_routes(self):
"""Setup API routes."""
# Authentication endpoints (public)
@self.app.post("/api/auth/login", response_model=TokenResponse)
async def login(request: LoginRequest):
"""Login and receive an access token."""
user = auth_manager.authenticate_user(request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth_manager.create_access_token(
data={"sub": user["username"]}
)
return TokenResponse(
access_token=access_token,
expires_in=auth_manager.access_token_expire_minutes * 60
)
@self.app.get("/api/auth/verify")
async def verify_token(current_user: Dict[str, Any] = Depends(get_current_user)):
"""Verify the current token is valid."""
return {
"valid": True,
"username": current_user["username"],
"is_admin": current_user["user"].get("is_admin", False)
}
# Public endpoints - HTML pages
@self.app.get("/login.html")
async def login_page():
"""Serve the login page."""
html_file = Path(__file__).parent / "static" / "login.html"
if html_file.exists():
return FileResponse(html_file, media_type="text/html")
else:
return HTMLResponse(content="<h1>Login page not found</h1>", status_code=404)
@self.app.get("/")
async def index():
"""Serve the main dashboard."""
html_file = Path(__file__).parent / "static" / "index.html"
if html_file.exists():
return FileResponse(html_file, media_type="text/html")
else:
# Return a basic HTML page if static file doesn't exist yet
return HTMLResponse(content="""
<!DOCTYPE html>
<html>
<head>
<title>Ralph Orchestrator Monitor</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.status { padding: 10px; margin: 10px 0; background: #f0f0f0; border-radius: 5px; }
</style>
</head>
<body>
<h1>Ralph Orchestrator Monitor</h1>
<div id="status" class="status">
<p>Web monitor is running. Dashboard file not found.</p>
<p>API Endpoints:</p>
<ul>
<li><a href="/api/status">/api/status</a> - System status</li>
<li><a href="/api/orchestrators">/api/orchestrators</a> - Active orchestrators</li>
<li><a href="/api/metrics">/api/metrics</a> - System metrics</li>
<li><a href="/docs">/docs</a> - API documentation</li>
</ul>
</div>
</body>
</html>
""")
# Create dependency for auth if enabled
auth_dependency = Depends(get_current_user) if self.enable_auth else None
@self.app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"timestamp": datetime.now().isoformat()
}
@self.app.get("/api/status", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_status():
"""Get overall system status."""
return {
"status": "online",
"timestamp": datetime.now().isoformat(),
"active_orchestrators": len(self.monitor.active_orchestrators),
"connected_clients": len(self.monitor.websocket_clients),
"system_metrics": self.monitor.metrics_cache.get("system", {})
}
@self.app.get("/api/orchestrators", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_orchestrators():
"""Get all active orchestrators."""
return {
"orchestrators": self.monitor.get_all_orchestrators_status(),
"count": len(self.monitor.active_orchestrators)
}
@self.app.get("/api/orchestrators/{orchestrator_id}", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_orchestrator(orchestrator_id: str):
"""Get specific orchestrator status."""
status = self.monitor.get_orchestrator_status(orchestrator_id)
if not status:
raise HTTPException(status_code=404, detail="Orchestrator not found")
return status
@self.app.get("/api/orchestrators/{orchestrator_id}/tasks", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_orchestrator_tasks(orchestrator_id: str):
"""Get task queue status for an orchestrator."""
if orchestrator_id not in self.monitor.active_orchestrators:
raise HTTPException(status_code=404, detail="Orchestrator not found")
orchestrator = self.monitor.active_orchestrators[orchestrator_id]
task_status = orchestrator.get_task_status()
return {
"orchestrator_id": orchestrator_id,
"tasks": task_status
}
@self.app.post("/api/orchestrators/{orchestrator_id}/pause", dependencies=[auth_dependency] if self.enable_auth else [])
async def pause_orchestrator(orchestrator_id: str):
"""Pause an orchestrator."""
if orchestrator_id not in self.monitor.active_orchestrators:
raise HTTPException(status_code=404, detail="Orchestrator not found")
orchestrator = self.monitor.active_orchestrators[orchestrator_id]
orchestrator.stop_requested = True
return {"status": "paused", "orchestrator_id": orchestrator_id}
@self.app.post("/api/orchestrators/{orchestrator_id}/resume", dependencies=[auth_dependency] if self.enable_auth else [])
async def resume_orchestrator(orchestrator_id: str):
"""Resume an orchestrator."""
if orchestrator_id not in self.monitor.active_orchestrators:
raise HTTPException(status_code=404, detail="Orchestrator not found")
orchestrator = self.monitor.active_orchestrators[orchestrator_id]
orchestrator.stop_requested = False
return {"status": "resumed", "orchestrator_id": orchestrator_id}
@self.app.get("/api/orchestrators/{orchestrator_id}/prompt", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_orchestrator_prompt(orchestrator_id: str):
"""Get the current prompt for an orchestrator."""
if orchestrator_id not in self.monitor.active_orchestrators:
raise HTTPException(status_code=404, detail="Orchestrator not found")
orchestrator = self.monitor.active_orchestrators[orchestrator_id]
prompt_file = orchestrator.prompt_file
if not prompt_file.exists():
raise HTTPException(status_code=404, detail="Prompt file not found")
content = prompt_file.read_text()
return {
"orchestrator_id": orchestrator_id,
"prompt_file": str(prompt_file),
"content": content,
"last_modified": prompt_file.stat().st_mtime
}
@self.app.post("/api/orchestrators/{orchestrator_id}/prompt", dependencies=[auth_dependency] if self.enable_auth else [])
async def update_orchestrator_prompt(orchestrator_id: str, request: PromptUpdateRequest):
"""Update the prompt for an orchestrator."""
if orchestrator_id not in self.monitor.active_orchestrators:
raise HTTPException(status_code=404, detail="Orchestrator not found")
orchestrator = self.monitor.active_orchestrators[orchestrator_id]
prompt_file = orchestrator.prompt_file
try:
# Create backup before updating
backup_file = prompt_file.with_suffix(f".{int(time.time())}.backup")
if prompt_file.exists():
backup_file.write_text(prompt_file.read_text())
# Write the new content
prompt_file.write_text(request.content)
# Notify the orchestrator of the update
if hasattr(orchestrator, '_reload_prompt'):
orchestrator._reload_prompt()
# Broadcast update to WebSocket clients
await self.monitor._broadcast_to_clients({
"type": "prompt_updated",
"data": {
"orchestrator_id": orchestrator_id,
"timestamp": datetime.now().isoformat()
}
})
return {
"status": "success",
"orchestrator_id": orchestrator_id,
"backup_file": str(backup_file),
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error updating prompt: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update prompt: {str(e)}") from e
@self.app.get("/api/metrics", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_metrics():
"""Get system metrics."""
return self.monitor.metrics_cache
@self.app.get("/api/history", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_history(limit: int = 50):
"""Get execution history from database.
Args:
limit: Maximum number of runs to return (default 50)
"""
try:
# Get recent runs from database
history = self.monitor.database.get_recent_runs(limit=limit)
return history
except Exception as e:
logger.error(f"Error fetching history from database: {e}")
# Fallback to file-based history if database fails
metrics_dir = Path(".agent") / "metrics"
history = []
if metrics_dir.exists():
for metrics_file in sorted(metrics_dir.glob("metrics_*.json")):
try:
data = json.loads(metrics_file.read_text())
data["filename"] = metrics_file.name
history.append(data)
except Exception as e:
logger.error(f"Error reading metrics file {metrics_file}: {e}")
return {"history": history[-50:]} # Return last 50 entries
@self.app.get("/api/history/{run_id}", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_run_details(run_id: int):
"""Get detailed information about a specific run.
Args:
run_id: ID of the run to retrieve
"""
run_details = self.monitor.database.get_run_details(run_id)
if not run_details:
raise HTTPException(status_code=404, detail="Run not found")
return run_details
@self.app.get("/api/statistics", dependencies=[auth_dependency] if self.enable_auth else [])
async def get_statistics():
"""Get database statistics."""
return self.monitor.database.get_statistics()
@self.app.post("/api/database/cleanup", dependencies=[auth_dependency] if self.enable_auth else [])
async def cleanup_database(days: int = 30):
"""Clean up old records from the database.
Args:
days: Number of days of history to keep (default 30)
"""
try:
self.monitor.database.cleanup_old_records(days=days)
return {"status": "success", "message": f"Cleaned up records older than {days} days"}
except Exception as e:
logger.error(f"Error cleaning up database: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e
# Admin endpoints for user management
@self.app.post("/api/admin/users", dependencies=[Depends(require_admin)] if self.enable_auth else [])
async def add_user(username: str, password: str, is_admin: bool = False):
"""Add a new user (admin only)."""
if auth_manager.add_user(username, password, is_admin):
return {"status": "success", "message": f"User {username} created"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists"
)
@self.app.delete("/api/admin/users/{username}", dependencies=[Depends(require_admin)] if self.enable_auth else [])
async def remove_user(username: str):
"""Remove a user (admin only)."""
if auth_manager.remove_user(username):
return {"status": "success", "message": f"User {username} removed"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove user"
)
@self.app.post("/api/auth/change-password", dependencies=[auth_dependency] if self.enable_auth else [])
async def change_password(
old_password: str,
new_password: str,
current_user: Dict[str, Any] = Depends(get_current_user) if self.enable_auth else None
):
"""Change the current user's password."""
if not self.enable_auth:
raise HTTPException(status_code=404, detail="Authentication not enabled")
# Verify old password
user = auth_manager.authenticate_user(current_user["username"], old_password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect old password"
)
# Update password
if auth_manager.update_password(current_user["username"], new_password):
return {"status": "success", "message": "Password updated"}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update password"
)
@self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = None):
"""WebSocket endpoint for real-time updates."""
# Verify token if auth is enabled
if self.enable_auth and token:
try:
auth_manager.verify_token(token)
except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
elif self.enable_auth:
# Auth is enabled but no token provided
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
self.monitor.websocket_clients.append(websocket)
# Send initial state
await websocket.send_json({
"type": "initial_state",
"data": {
"orchestrators": self.monitor.get_all_orchestrators_status(),
"system_metrics": self.monitor.metrics_cache.get("system", {})
}
})
try:
while True:
# Keep connection alive and handle incoming messages
data = await websocket.receive_text()
# Handle ping/pong or other commands if needed
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
self.monitor.websocket_clients.remove(websocket)
logger.info("WebSocket client disconnected")
def run(self):
"""Run the web server."""
logger.info(f"Starting web monitor on {self.host}:{self.port}")
uvicorn.run(self.app, host=self.host, port=self.port)
async def arun(self):
"""Run the web server asynchronously."""
logger.info(f"Starting web monitor on {self.host}:{self.port}")
config = uvicorn.Config(app=self.app, host=self.host, port=self.port)
server = uvicorn.Server(config)
await server.serve()
def register_orchestrator(self, orchestrator_id: str, orchestrator: RalphOrchestrator):
"""Register an orchestrator with the monitor."""
self.monitor.register_orchestrator(orchestrator_id, orchestrator)
def unregister_orchestrator(self, orchestrator_id: str):
"""Unregister an orchestrator from the monitor."""
self.monitor.unregister_orchestrator(orchestrator_id)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ralph Orchestrator - Login</title>
<style>
:root {
--primary-color: #4a5568;
--secondary-color: #718096;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--background: #f7fafc;
--surface: #ffffff;
--text-primary: #2d3748;
--text-secondary: #718096;
--border-color: #e2e8f0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: var(--surface);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 400px;
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
color: var(--primary-color);
font-size: 28px;
margin-bottom: 10px;
}
.login-header p {
color: var(--text-secondary);
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
background: var(--surface);
color: var(--text-primary);
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 85, 104, 0.1);
}
.btn-login {
width: 100%;
padding: 12px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
}
.btn-login:hover {
background: #2d3748;
transform: translateY(-1px);
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
background: var(--secondary-color);
cursor: not-allowed;
transform: none;
}
.error-message {
background: rgba(245, 101, 101, 0.1);
border: 1px solid var(--danger-color);
color: var(--danger-color);
padding: 10px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.error-message.show {
display: block;
}
.success-message {
background: rgba(72, 187, 120, 0.1);
border: 1px solid var(--success-color);
color: var(--success-color);
padding: 10px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.success-message.show {
display: block;
}
.form-footer {
margin-top: 20px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.6s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-info {
background: rgba(74, 85, 104, 0.05);
border-left: 3px solid var(--primary-color);
padding: 12px;
margin-top: 20px;
font-size: 13px;
color: var(--text-secondary);
}
.auth-info strong {
color: var(--text-primary);
}
@media (max-width: 480px) {
.login-container {
padding: 30px 20px;
}
.login-header h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>🤖 Ralph Orchestrator</h1>
<p>Monitoring Dashboard Login</p>
</div>
<div id="errorMessage" class="error-message"></div>
<div id="successMessage" class="success-message"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn-login" id="loginButton">
Login
</button>
</form>
<div class="auth-info">
<strong>Default Credentials:</strong><br>
Username: admin<br>
Password: Set via RALPH_WEB_PASSWORD env variable<br>
(Default: ralph-admin-2024)
</div>
<div class="form-footer">
Secure access to Ralph Orchestrator monitoring
</div>
</div>
<script>
// Check if already authenticated
const token = localStorage.getItem('ralph_auth_token');
if (token) {
// Verify token is still valid
fetch('/api/auth/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
}).then(response => {
if (response.ok) {
// Token is valid, redirect to dashboard
window.location.href = '/';
} else {
// Token is invalid, remove it
localStorage.removeItem('ralph_auth_token');
}
});
}
// Handle login form submission
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const loginButton = document.getElementById('loginButton');
const errorMessage = document.getElementById('errorMessage');
const successMessage = document.getElementById('successMessage');
// Reset messages
errorMessage.classList.remove('show');
successMessage.classList.remove('show');
// Disable button and show loading
loginButton.disabled = true;
loginButton.innerHTML = 'Logging in<span class="loading-spinner"></span>';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
// Store token
localStorage.setItem('ralph_auth_token', data.access_token);
// Show success message
successMessage.textContent = 'Login successful! Redirecting...';
successMessage.classList.add('show');
// Redirect to dashboard
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
// Show error
errorMessage.textContent = data.detail || 'Login failed. Please check your credentials.';
errorMessage.classList.add('show');
// Re-enable button
loginButton.disabled = false;
loginButton.textContent = 'Login';
}
} catch (error) {
// Show error
errorMessage.textContent = 'Connection error. Please try again.';
errorMessage.classList.add('show');
// Re-enable button
loginButton.disabled = false;
loginButton.textContent = 'Login';
}
});
</script>
</body>
</html>