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:
@@ -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']
|
||||
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user