Build bot management dashboard system #1

Merged
overspend1 merged 1 commits from claude/bot-management-dashboard-019eUkzJ11Nebi3NoaADESdy into main 2025-11-21 11:34:54 +01:00
67 changed files with 5326 additions and 1 deletions

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
env/
ENV/
.venv
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
bots/logs/*.log
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Next.js
.next/
out/
next-env.d.ts
# Build
dist/
build/
# Docker
.dockerignore
# Bot session files
*.session
*.session-journal
# Misc
.cache/
.temp/
tmp/

85
Makefile Normal file
View File

@@ -0,0 +1,85 @@
# Makefile for Bot Management Dashboard
.PHONY: help dev build up down logs clean install test format lint
# Default target
help:
@echo "Bot Management Dashboard - Available commands:"
@echo ""
@echo " make dev - Start development environment"
@echo " make build - Build Docker images"
@echo " make up - Start all services"
@echo " make down - Stop all services"
@echo " make logs - View logs"
@echo " make clean - Clean up containers and volumes"
@echo " make install - Install dependencies"
@echo " make test - Run tests"
@echo " make format - Format code"
@echo " make lint - Lint code"
# Development
dev:
docker-compose up
# Build images
build:
docker-compose build
# Start services
up:
docker-compose up -d
# Stop services
down:
docker-compose down
# View logs
logs:
docker-compose logs -f
# Clean up
clean:
docker-compose down -v
rm -rf backend/__pycache__
rm -rf backend/app/__pycache__
rm -rf frontend/.next
rm -rf frontend/node_modules
rm -rf backend/venv
# Install dependencies
install:
cd backend && pip install -r requirements.txt
cd frontend && npm install
cd bots/examples && pip install -r requirements.txt
# Run tests
test:
cd backend && pytest
cd frontend && npm run test
# Format code
format:
cd backend && black app/
cd frontend && npm run format
# Lint code
lint:
cd backend && flake8 app/
cd frontend && npm run lint
# Database
db-init:
cd backend && python -c "from app.database import init_db; init_db()"
# Production
prod-build:
docker-compose -f docker-compose.prod.yml build
prod-up:
docker-compose -f docker-compose.prod.yml up -d
prod-down:
docker-compose -f docker-compose.prod.yml down
prod-logs:
docker-compose -f docker-compose.prod.yml logs -f

463
README.md
View File

@@ -1 +1,462 @@
# bot-dashboard
# Bot Management Dashboard
A modern, production-ready web dashboard for managing Telegram userbots, Telegram bots, and Discord bots from a single, beautiful interface.
![Dashboard Preview](https://via.placeholder.com/800x400?text=Bot+Management+Dashboard)
## ✨ Features
- **Multi-Platform Support**: Manage Telegram userbots, Telegram bots, and Discord bots
- **Real-Time Monitoring**: Live log streaming via WebSocket, real-time system statistics
- **Process Management**: Start, stop, restart bots with one click
- **Resource Tracking**: Monitor CPU, RAM usage per bot and system-wide
- **Auto-Restart**: Automatic bot restart on crashes with configurable backoff
- **Beautiful UI**: Dark theme with glassmorphism effects, built with Next.js 14 and Tailwind CSS
- **Responsive Design**: Works perfectly on mobile, tablet, and desktop
- **RESTful API**: Comprehensive API with OpenAPI documentation
- **Docker Ready**: Full Docker and Docker Compose support for easy deployment
- **Production Ready**: Security best practices, proper error handling, logging
## 🛠️ Tech Stack
### Backend
- **FastAPI** - Modern, fast web framework for Python
- **SQLAlchemy** - SQL toolkit and ORM
- **SQLite/PostgreSQL** - Database (SQLite for dev, PostgreSQL for production)
- **WebSockets** - Real-time communication
- **Uvicorn** - ASGI server
- **JWT** - Authentication
- **Psutil** - System and process monitoring
### Frontend
- **Next.js 14** - React framework with App Router
- **TypeScript** - Type safety
- **Tailwind CSS** - Utility-first CSS framework
- **shadcn/ui** - High-quality React components
- **TanStack Query** - Data fetching and caching
- **Axios** - HTTP client
- **Recharts** - Charts and graphs
- **Sonner** - Toast notifications
### Infrastructure
- **Docker** - Containerization
- **Docker Compose** - Multi-container orchestration
- **Nginx** - Reverse proxy and load balancer
## 📋 Prerequisites
- **Python 3.11+** for backend development
- **Node.js 20+** for frontend development
- **Docker & Docker Compose** (optional, for containerized deployment)
- **Git** for version control
## 🚀 Quick Start with Docker
The fastest way to get started:
```bash
# Clone the repository
git clone <repository-url>
cd bot-dashboard
# Copy environment file
cp backend/.env.example backend/.env
# Generate a secure secret key
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# Add the generated key to backend/.env as SECRET_KEY
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
```
Access the dashboard at **http://localhost:3000**
API documentation available at **http://localhost:8000/docs**
## 💻 Local Development Setup
### Backend Setup
```bash
cd backend
# Create virtual environment
python3 -m venv venv
# Activate virtual environment
# On Linux/Mac:
source venv/bin/activate
# On Windows:
venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Copy environment file
cp .env.example .env
# Initialize database
python -c "from app.database import init_db; init_db()"
# Start development server
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
Backend will be available at http://localhost:8000
### Frontend Setup
```bash
cd frontend
# Install dependencies
npm install
# Copy environment file
cp .env.local.example .env.local
# Start development server
npm run dev
```
Frontend will be available at http://localhost:3000
### Installing Bot Dependencies
```bash
cd bots/examples
# Install bot dependencies
pip install -r requirements.txt
```
## 📱 Adding Your First Bot
### Via Web Interface
1. Open the dashboard at http://localhost:3000
2. Click "Add Bot" button
3. Fill in bot details:
- **Name**: A unique name for your bot
- **Type**: Select bot type (Telegram Userbot, Telegram Bot, or Discord Bot)
- **Configuration**: Enter bot credentials (tokens, API keys, etc.)
- **Auto-restart**: Enable/disable automatic restart on crash
4. Click "Create"
5. Start the bot using the "Start" button on the bot card
### Via API
```bash
curl -X POST http://localhost:8000/api/v1/bots \
-H "Content-Type: application/json" \
-d '{
"name": "My Telegram Bot",
"type": "telegram_bot",
"config": {
"token": "YOUR_BOT_TOKEN_HERE"
},
"auto_restart": true
}'
```
## 🤖 Bot Configuration Examples
### Telegram Bot
```json
{
"name": "My Telegram Bot",
"type": "telegram_bot",
"config": {
"token": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
},
"auto_restart": true
}
```
### Telegram Userbot
```json
{
"name": "My Telegram Userbot",
"type": "telegram_userbot",
"config": {
"api_id": "12345678",
"api_hash": "0123456789abcdef0123456789abcdef",
"phone": "+1234567890",
"session_name": "my_session"
},
"auto_restart": true
}
```
### Discord Bot
```json
{
"name": "My Discord Bot",
"type": "discord_bot",
"config": {
"token": "YOUR_DISCORD_BOT_TOKEN",
"prefix": "!"
},
"auto_restart": true
}
```
## 🔧 Configuration
### Environment Variables
#### Backend (.env)
```bash
# Database
DATABASE_URL=sqlite:///./data/bot_dashboard.db
# Security
SECRET_KEY=your-secret-key-here-change-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7
# CORS
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Logging
LOG_LEVEL=INFO
MAX_LOG_SIZE_MB=10
# Bot Management
AUTO_RESTART_BOTS=true
STATS_COLLECTION_INTERVAL=5
BOT_PROCESS_CHECK_INTERVAL=5
BOT_RESTART_BACKOFF_SECONDS=10
```
#### Frontend (.env.local)
```bash
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000
```
## 📖 API Documentation
### Authentication
```bash
# Register
POST /api/v1/auth/register
{
"username": "admin",
"email": "admin@example.com",
"password": "secure_password"
}
# Login
POST /api/v1/auth/login
{
"username": "admin",
"password": "secure_password"
}
```
### Bot Management
```bash
# List all bots
GET /api/v1/bots?page=1&page_size=20
# Get bot details
GET /api/v1/bots/{bot_id}
# Create bot
POST /api/v1/bots
# Update bot
PUT /api/v1/bots/{bot_id}
# Delete bot
DELETE /api/v1/bots/{bot_id}
# Start bot
POST /api/v1/bots/{bot_id}/start
# Stop bot
POST /api/v1/bots/{bot_id}/stop
# Restart bot
POST /api/v1/bots/{bot_id}/restart
# Get bot status
GET /api/v1/bots/{bot_id}/status
# Get bot logs
GET /api/v1/bots/{bot_id}/logs?page=1
```
### Statistics
```bash
# System stats
GET /api/v1/stats/system
# Bot stats
GET /api/v1/stats/bots/{bot_id}
# All bots stats
GET /api/v1/stats/bots
```
### WebSocket Endpoints
```bash
# Real-time logs
WS /ws/logs/{bot_id}
# Real-time stats
WS /ws/stats
```
Full API documentation: http://localhost:8000/docs
## 🐳 Production Deployment
### Using Docker Compose
```bash
# Copy production compose file
cp docker-compose.prod.yml docker-compose.yml
# Set environment variables
export SECRET_KEY="your-production-secret-key"
export CORS_ORIGINS="https://yourdomain.com"
export API_URL="https://yourdomain.com"
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
```
### Manual Deployment
1. **Backend**:
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
2. **Frontend**:
```bash
cd frontend
npm install
npm run build
npm start
```
3. **Nginx**: Configure nginx as reverse proxy (see nginx/nginx.conf)
## 🔒 Security Considerations
- **Change default SECRET_KEY** in production
- **Use HTTPS** in production (configure SSL certificates in nginx)
- **Enable rate limiting** (configured in nginx)
- **Implement authentication** for production use
- **Secure WebSocket connections** (use wss:// in production)
- **Keep dependencies updated** regularly
- **Use environment variables** for all secrets
- **Enable firewall** on production servers
- **Regular backups** of database
## 📊 Monitoring
The dashboard provides real-time monitoring:
- **System Metrics**: CPU, RAM, disk, network usage
- **Bot Status**: Running, stopped, crashed states
- **Process Info**: PIDs, uptime, resource usage per bot
- **Live Logs**: Real-time log streaming via WebSocket
- **Statistics**: Aggregated metrics across all bots
## 🛠️ Development
### Using Makefile
```bash
# View available commands
make help
# Start development environment
make dev
# Install dependencies
make install
# Run tests
make test
# Format code
make format
# Lint code
make lint
```
## 📝 Troubleshooting
### Bot won't start
- Check bot credentials in configuration
- Verify bot dependencies are installed
- Check logs for error messages
- Ensure API tokens are valid
### WebSocket connection fails
- Verify CORS settings
- Check firewall rules
- Ensure WebSocket URL is correct
- Check nginx configuration
### Database errors
- Ensure database file permissions
- Check DATABASE_URL environment variable
- Run database migrations
- Verify disk space
## 🤝 Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
## 📄 License
This project is licensed under the MIT License.
## 🙏 Acknowledgments
- [FastAPI](https://fastapi.tiangolo.com/)
- [Next.js](https://nextjs.org/)
- [shadcn/ui](https://ui.shadcn.com/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Telethon](https://docs.telethon.dev/)
- [python-telegram-bot](https://python-telegram-bot.org/)
- [discord.py](https://discordpy.readthedocs.io/)
---
**Made with ❤️ for bot developers**

10
backend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
DATABASE_URL=sqlite:///./data/bot_dashboard.db
SECRET_KEY=your-secret-key-here-change-in-production
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
LOG_LEVEL=INFO
MAX_LOG_SIZE_MB=10
AUTO_RESTART_BOTS=true
STATS_COLLECTION_INTERVAL=5
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7

45
backend/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Multi-stage build for Python backend
FROM python:3.11-slim as builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Production stage
FROM python:3.11-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
# Copy application code
COPY ./app ./app
# Create directories
RUN mkdir -p /app/data /app/bots/logs /app/bots/configs /app/bots/examples
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Bot Management Dashboard - Backend Application"""
__version__ = "1.0.0"

58
backend/app/config.py Normal file
View File

@@ -0,0 +1,58 @@
"""Application configuration using pydantic-settings."""
from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
DATABASE_URL: str = "sqlite:///./data/bot_dashboard.db"
# Security
SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000"
# Logging
LOG_LEVEL: str = "INFO"
MAX_LOG_SIZE_MB: int = 10
# Bot Management
AUTO_RESTART_BOTS: bool = True
STATS_COLLECTION_INTERVAL: int = 5
BOT_PROCESS_CHECK_INTERVAL: int = 5
BOT_RESTART_BACKOFF_SECONDS: int = 10
BOT_SHUTDOWN_TIMEOUT: int = 10
# Paths
BOTS_DIR: str = "./bots"
LOGS_DIR: str = "./bots/logs"
CONFIGS_DIR: str = "./bots/configs"
# WebSocket
WS_HEARTBEAT_INTERVAL: int = 30
WS_LOG_BUFFER_SIZE: int = 100
# Rate Limiting
RATE_LIMIT_PER_SECOND: int = 10
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=True,
extra="ignore"
)
@property
def cors_origins_list(self) -> List[str]:
"""Parse CORS origins string into list."""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
# Global settings instance
settings = Settings()

46
backend/app/database.py Normal file
View File

@@ -0,0 +1,46 @@
"""Database connection and session management."""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from typing import Generator
from app.config import settings
# Create engine with appropriate settings based on database type
if settings.DATABASE_URL.startswith("sqlite"):
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
else:
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator:
"""
Dependency for getting database session.
Yields:
Database session that automatically closes after use
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
"""Initialize database tables."""
import app.models.bot # noqa: F401
import app.models.log # noqa: F401
import app.models.user # noqa: F401
Base.metadata.create_all(bind=engine)

193
backend/app/main.py Normal file
View File

@@ -0,0 +1,193 @@
"""Main FastAPI application."""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import time
from app.config import settings
from app.database import init_db, SessionLocal
from app.services.bot_manager import bot_manager
from app.routers import bots, auth, stats, websocket
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Handles startup and shutdown events.
"""
# Startup
logger.info("Starting Bot Management Dashboard...")
# Ensure required directories exist
os.makedirs(settings.BOTS_DIR, exist_ok=True)
os.makedirs(settings.LOGS_DIR, exist_ok=True)
os.makedirs(settings.CONFIGS_DIR, exist_ok=True)
os.makedirs("./data", exist_ok=True)
# Initialize database
logger.info("Initializing database...")
init_db()
# Start bot manager monitoring
logger.info("Starting bot manager...")
bot_manager.start_monitoring()
# Load existing bots from database
db = SessionLocal()
try:
bot_manager.load_bots_from_db(db)
finally:
db.close()
logger.info("Bot Management Dashboard started successfully!")
yield
# Shutdown
logger.info("Shutting down Bot Management Dashboard...")
# Stop all bots
db = SessionLocal()
try:
bot_manager.stop_all_bots(db)
finally:
db.close()
# Stop monitoring
bot_manager.stop_monitoring()
logger.info("Bot Management Dashboard shut down successfully!")
# Create FastAPI application
app = FastAPI(
title="Bot Management Dashboard API",
description="API for managing Telegram and Discord bots",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request logging middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all incoming requests with timing."""
request_id = f"{int(time.time() * 1000)}"
start_time = time.time()
logger.info(f"[{request_id}] {request.method} {request.url.path}")
try:
response = await call_next(request)
duration = time.time() - start_time
logger.info(
f"[{request_id}] {request.method} {request.url.path} "
f"- Status: {response.status_code} - Duration: {duration:.3f}s"
)
return response
except Exception as e:
duration = time.time() - start_time
logger.error(
f"[{request_id}] {request.method} {request.url.path} "
f"- Error: {str(e)} - Duration: {duration:.3f}s"
)
raise
# Exception handlers
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors with detailed messages."""
logger.warning(f"Validation error for {request.url.path}: {exc.errors()}")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"detail": exc.errors(),
"body": exc.body if hasattr(exc, "body") else None,
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle unexpected errors."""
logger.error(f"Unexpected error for {request.url.path}: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "Internal server error",
"message": str(exc) if settings.LOG_LEVEL == "DEBUG" else "An error occurred",
},
)
# Health check endpoint
@app.get("/health", tags=["Health"])
def health_check():
"""
Health check endpoint.
Returns:
Status of the API
"""
return {
"status": "healthy",
"version": "1.0.0",
"environment": "production" if settings.SECRET_KEY != "dev-secret-key-change-in-production" else "development",
}
# Root endpoint
@app.get("/", tags=["Root"])
def root():
"""
Root endpoint with API information.
Returns:
API welcome message
"""
return {
"message": "Bot Management Dashboard API",
"version": "1.0.0",
"docs": "/docs",
"health": "/health",
}
# Include routers
app.include_router(auth.router)
app.include_router(bots.router)
app.include_router(stats.router)
app.include_router(websocket.router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level=settings.LOG_LEVEL.lower(),
)

View File

@@ -0,0 +1,7 @@
"""Database models."""
from app.models.bot import Bot
from app.models.log import LogEntry
from app.models.user import User
__all__ = ["Bot", "LogEntry", "User"]

50
backend/app/models/bot.py Normal file
View File

@@ -0,0 +1,50 @@
"""Bot model for database."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Enum as SQLEnum, JSON
from sqlalchemy.orm import relationship
import enum
from app.database import Base
class BotType(str, enum.Enum):
"""Supported bot types."""
TELEGRAM_USERBOT = "telegram_userbot"
TELEGRAM_BOT = "telegram_bot"
DISCORD_BOT = "discord_bot"
class BotStatus(str, enum.Enum):
"""Bot operational status."""
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
STOPPING = "stopping"
CRASHED = "crashed"
class Bot(Base):
"""Bot model representing a managed bot instance."""
__tablename__ = "bots"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True)
type = Column(SQLEnum(BotType), nullable=False)
config = Column(JSON, nullable=False)
status = Column(SQLEnum(BotStatus), default=BotStatus.STOPPED, nullable=False)
auto_restart = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
last_started_at = Column(DateTime, nullable=True)
process_id = Column(Integer, nullable=True)
restart_count = Column(Integer, default=0, nullable=False)
last_crash_at = Column(DateTime, nullable=True)
# Relationships
logs = relationship("LogEntry", back_populates="bot", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<Bot(id={self.id}, name={self.name}, type={self.type}, status={self.status})>"

35
backend/app/models/log.py Normal file
View File

@@ -0,0 +1,35 @@
"""Log entry model for database."""
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Text, Enum as SQLEnum, ForeignKey
from sqlalchemy.orm import relationship
import enum
from app.database import Base
class LogLevel(str, enum.Enum):
"""Log severity levels."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class LogEntry(Base):
"""Log entry model for bot logs."""
__tablename__ = "log_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
bot_id = Column(String(36), ForeignKey("bots.id", ondelete="CASCADE"), nullable=False, index=True)
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
level = Column(SQLEnum(LogLevel), default=LogLevel.INFO, nullable=False)
message = Column(Text, nullable=False)
# Relationships
bot = relationship("Bot", back_populates="logs")
def __repr__(self) -> str:
return f"<LogEntry(id={self.id}, bot_id={self.bot_id}, level={self.level})>"

View File

@@ -0,0 +1,24 @@
"""User model for authentication."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime
from app.database import Base
class User(Base):
"""User model for authentication and authorization."""
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
return f"<User(id={self.id}, username={self.username}, email={self.email})>"

View File

@@ -0,0 +1,5 @@
"""API routers."""
from app.routers import bots, auth, stats, websocket
__all__ = ["bots", "auth", "stats", "websocket"]

152
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,152 @@
"""Authentication router for user login and registration."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
from app.utils.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
)
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""
Register a new user.
Args:
user_data: User registration data
db: Database session
Returns:
Created user information
Raises:
HTTPException: If username or email already exists
"""
# Check if username exists
existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email exists
existing_email = db.query(User).filter(User.email == user_data.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashed_password,
)
db.add(new_user)
db.commit()
db.refresh(new_user)
logger.info(f"New user registered: {new_user.username}")
return new_user
@router.post("/login", response_model=Token)
def login(credentials: UserLogin, db: Session = Depends(get_db)):
"""
Login and receive JWT tokens.
Args:
credentials: Login credentials
db: Database session
Returns:
Access and refresh tokens
Raises:
HTTPException: If credentials are invalid
"""
# Find user
user = db.query(User).filter(User.username == credentials.username).first()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
)
# Create tokens
token_data = {"user_id": user.id, "username": user.username}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
logger.info(f"User logged in: {user.username}")
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
}
@router.post("/refresh", response_model=Token)
def refresh(refresh_token: str, db: Session = Depends(get_db)):
"""
Refresh access token using refresh token.
Args:
refresh_token: Valid refresh token
db: Database session
Returns:
New access and refresh tokens
Raises:
HTTPException: If refresh token is invalid
"""
from app.utils.security import verify_token
payload = verify_token(refresh_token, token_type="refresh")
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# Verify user still exists and is active
user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# Create new tokens
token_data = {"user_id": user.id, "username": user.username}
new_access_token = create_access_token(token_data)
new_refresh_token = create_refresh_token(token_data)
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer"
}

413
backend/app/routers/bots.py Normal file
View File

@@ -0,0 +1,413 @@
"""Bot management router for CRUD and control operations."""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.models.bot import Bot, BotStatus
from app.schemas.bot import (
BotCreate,
BotUpdate,
BotResponse,
BotListResponse,
BotStatusResponse,
)
from app.schemas.log import LogListResponse, LogEntryResponse
from app.services.bot_manager import bot_manager
from app.models.log import LogEntry
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
router = APIRouter(prefix="/api/v1/bots", tags=["Bots"])
@router.get("", response_model=BotListResponse)
def list_bots(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status: Optional[BotStatus] = Query(None, description="Filter by status"),
search: Optional[str] = Query(None, description="Search by name"),
db: Session = Depends(get_db)
):
"""
List all bots with pagination and filtering.
Args:
page: Page number (starting from 1)
page_size: Number of items per page
status: Optional status filter
search: Optional name search
db: Database session
Returns:
Paginated list of bots
"""
query = db.query(Bot)
# Apply filters
if status:
query = query.filter(Bot.status == status)
if search:
query = query.filter(Bot.name.ilike(f"%{search}%"))
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * page_size
bots = query.offset(offset).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"bots": bots,
}
@router.get("/{bot_id}", response_model=BotResponse)
def get_bot(bot_id: str, db: Session = Depends(get_db)):
"""
Get bot details by ID.
Args:
bot_id: Bot ID
db: Database session
Returns:
Bot details
Raises:
HTTPException: If bot not found
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
return bot
@router.post("", response_model=BotResponse, status_code=status.HTTP_201_CREATED)
def create_bot(bot_data: BotCreate, db: Session = Depends(get_db)):
"""
Create a new bot.
Args:
bot_data: Bot creation data
db: Database session
Returns:
Created bot
Raises:
HTTPException: If bot name already exists
"""
# Check if name exists
existing_bot = db.query(Bot).filter(Bot.name == bot_data.name).first()
if existing_bot:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Bot with name '{bot_data.name}' already exists"
)
# Create bot
new_bot = Bot(
name=bot_data.name,
type=bot_data.type,
config=bot_data.config,
auto_restart=bot_data.auto_restart,
status=BotStatus.STOPPED,
)
db.add(new_bot)
db.commit()
db.refresh(new_bot)
logger.info(f"Created new bot: {new_bot.name} (ID: {new_bot.id})")
return new_bot
@router.put("/{bot_id}", response_model=BotResponse)
def update_bot(bot_id: str, bot_data: BotUpdate, db: Session = Depends(get_db)):
"""
Update bot configuration.
Args:
bot_id: Bot ID
bot_data: Update data
db: Database session
Returns:
Updated bot
Raises:
HTTPException: If bot not found or running
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
# Don't allow updates while running
if bot.status == BotStatus.RUNNING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot update bot while it is running. Stop it first."
)
# Update fields
if bot_data.name is not None:
# Check name uniqueness
existing = db.query(Bot).filter(
Bot.name == bot_data.name,
Bot.id != bot_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Bot with name '{bot_data.name}' already exists"
)
bot.name = bot_data.name
if bot_data.config is not None:
bot.config = bot_data.config
if bot_data.auto_restart is not None:
bot.auto_restart = bot_data.auto_restart
db.commit()
db.refresh(bot)
logger.info(f"Updated bot: {bot.name} (ID: {bot.id})")
return bot
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_bot(bot_id: str, db: Session = Depends(get_db)):
"""
Delete a bot.
Args:
bot_id: Bot ID
db: Database session
Raises:
HTTPException: If bot not found
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
# Stop bot if running
if bot.status in [BotStatus.RUNNING, BotStatus.STARTING]:
bot_manager.stop_bot(bot_id, db)
# Delete from database
db.delete(bot)
db.commit()
logger.info(f"Deleted bot: {bot.name} (ID: {bot.id})")
return None
@router.post("/{bot_id}/start", response_model=BotResponse)
def start_bot(bot_id: str, db: Session = Depends(get_db)):
"""
Start a bot process.
Args:
bot_id: Bot ID
db: Database session
Returns:
Updated bot with running status
Raises:
HTTPException: If bot not found or start fails
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
if bot.status == BotStatus.RUNNING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot is already running"
)
success = bot_manager.start_bot(bot_id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start bot"
)
db.refresh(bot)
logger.info(f"Started bot: {bot.name} (ID: {bot.id})")
return bot
@router.post("/{bot_id}/stop", response_model=BotResponse)
def stop_bot(bot_id: str, db: Session = Depends(get_db)):
"""
Stop a bot process.
Args:
bot_id: Bot ID
db: Database session
Returns:
Updated bot with stopped status
Raises:
HTTPException: If bot not found or stop fails
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
if bot.status == BotStatus.STOPPED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot is already stopped"
)
success = bot_manager.stop_bot(bot_id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to stop bot"
)
db.refresh(bot)
logger.info(f"Stopped bot: {bot.name} (ID: {bot.id})")
return bot
@router.post("/{bot_id}/restart", response_model=BotResponse)
def restart_bot(bot_id: str, db: Session = Depends(get_db)):
"""
Restart a bot process.
Args:
bot_id: Bot ID
db: Database session
Returns:
Updated bot
Raises:
HTTPException: If bot not found or restart fails
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
success = bot_manager.restart_bot(bot_id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to restart bot"
)
db.refresh(bot)
logger.info(f"Restarted bot: {bot.name} (ID: {bot.id})")
return bot
@router.get("/{bot_id}/status", response_model=BotStatusResponse)
def get_bot_status(bot_id: str, db: Session = Depends(get_db)):
"""
Get current bot status and runtime information.
Args:
bot_id: Bot ID
db: Database session
Returns:
Bot status information
Raises:
HTTPException: If bot not found
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
status_info = bot_manager.get_bot_status(bot_id)
uptime = status_info.get("uptime") if status_info else None
return {
"id": bot.id,
"name": bot.name,
"status": bot.status,
"process_id": bot.process_id,
"uptime_seconds": uptime,
"last_started_at": bot.last_started_at,
}
@router.get("/{bot_id}/logs", response_model=LogListResponse)
def get_bot_logs(
bot_id: str,
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
db: Session = Depends(get_db)
):
"""
Get paginated bot logs from database.
Args:
bot_id: Bot ID
page: Page number
page_size: Items per page
db: Database session
Returns:
Paginated log entries
Raises:
HTTPException: If bot not found
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
# Query logs
query = db.query(LogEntry).filter(LogEntry.bot_id == bot_id).order_by(
LogEntry.timestamp.desc()
)
total = query.count()
offset = (page - 1) * page_size
logs = query.offset(offset).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"logs": logs,
}

View File

@@ -0,0 +1,114 @@
"""Statistics router for system and bot metrics."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.bot import Bot, BotStatus
from app.schemas.stats import SystemStats, BotStats, AggregateStats
from app.services.stats_collector import StatsCollector
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
router = APIRouter(prefix="/api/v1/stats", tags=["Statistics"])
@router.get("/system", response_model=SystemStats)
def get_system_stats(db: Session = Depends(get_db)):
"""
Get overall system statistics.
Args:
db: Database session
Returns:
System metrics including CPU, RAM, disk, network, and bot counts
"""
# Get system metrics
system_stats = StatsCollector.get_system_stats()
# Get bot counts
bots_total = db.query(Bot).count()
bots_running = db.query(Bot).filter(Bot.status == BotStatus.RUNNING).count()
bots_stopped = db.query(Bot).filter(Bot.status == BotStatus.STOPPED).count()
bots_crashed = db.query(Bot).filter(Bot.status == BotStatus.CRASHED).count()
return {
**system_stats,
"bots_total": bots_total,
"bots_running": bots_running,
"bots_stopped": bots_stopped,
"bots_crashed": bots_crashed,
}
@router.get("/bots/{bot_id}", response_model=BotStats)
def get_bot_stats(bot_id: str, db: Session = Depends(get_db)):
"""
Get statistics for a specific bot.
Args:
bot_id: Bot ID
db: Database session
Returns:
Bot metrics including CPU, RAM, and uptime
Raises:
HTTPException: If bot not found or not running
"""
# Verify bot exists
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Bot {bot_id} not found"
)
# Get bot stats
stats = StatsCollector.get_bot_stats(bot_id)
if not stats:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot is not running or stats unavailable"
)
return {
**stats,
"bot_name": bot.name,
"status": bot.status.value,
}
@router.get("/bots", response_model=AggregateStats)
def get_all_bots_stats(db: Session = Depends(get_db)):
"""
Get aggregate statistics for all bots.
Args:
db: Database session
Returns:
Aggregate metrics across all running bots
"""
# Get all bot stats
bot_stats_list = StatsCollector.get_all_bots_stats()
# Enrich with bot names and status
enriched_stats = []
for bot_stat in bot_stats_list:
bot = db.query(Bot).filter(Bot.id == bot_stat["bot_id"]).first()
if bot:
enriched_stats.append({
**bot_stat,
"bot_name": bot.name,
"status": bot.status.value,
})
# Get aggregate stats
aggregate = StatsCollector.get_aggregate_stats(enriched_stats)
return {
**aggregate,
"bot_stats": enriched_stats,
}

View File

@@ -0,0 +1,192 @@
"""WebSocket router for real-time log streaming."""
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from sqlalchemy.orm import Session
from typing import Dict, Set
from datetime import datetime
from app.database import get_db
from app.models.bot import Bot
from app.services.log_collector import LogCollector
from app.services.stats_collector import StatsCollector
from app.utils.logger import setup_logger
from app.config import settings
logger = setup_logger(__name__)
router = APIRouter(tags=["WebSocket"])
# Active WebSocket connections
active_connections: Dict[str, Set[WebSocket]] = {}
stats_connections: Set[WebSocket] = set()
@router.websocket("/ws/logs/{bot_id}")
async def websocket_logs(websocket: WebSocket, bot_id: str):
"""
WebSocket endpoint for streaming bot logs in real-time.
Args:
websocket: WebSocket connection
bot_id: Bot ID to stream logs for
"""
await websocket.accept()
# Verify bot exists
db: Session = next(get_db())
try:
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
await websocket.close(code=4004, reason="Bot not found")
return
logger.info(f"WebSocket connection established for bot {bot.name} logs")
# Add to active connections
if bot_id not in active_connections:
active_connections[bot_id] = set()
active_connections[bot_id].add(websocket)
# Create log collector
log_collector = LogCollector(bot_id)
# Send buffered logs first
buffered_logs = log_collector.get_buffered_logs()
for log_line in buffered_logs:
try:
await websocket.send_json({
"timestamp": datetime.utcnow().isoformat(),
"level": "INFO",
"message": log_line.strip()
})
except Exception as e:
logger.error(f"Error sending buffered log: {e}")
break
# Stream new logs
try:
# Start heartbeat task
async def heartbeat():
while True:
try:
await asyncio.sleep(settings.WS_HEARTBEAT_INTERVAL)
await websocket.send_json({"type": "ping"})
except Exception:
break
heartbeat_task = asyncio.create_task(heartbeat())
# Stream logs
async for log_line in log_collector.stream_logs():
try:
await websocket.send_json({
"timestamp": datetime.utcnow().isoformat(),
"level": "INFO",
"message": log_line.strip()
})
except WebSocketDisconnect:
break
except Exception as e:
logger.error(f"Error streaming log: {e}")
break
heartbeat_task.cancel()
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for bot {bot.name}")
except Exception as e:
logger.error(f"WebSocket error for bot {bot.name}: {e}")
finally:
# Remove from active connections
if bot_id in active_connections:
active_connections[bot_id].discard(websocket)
if not active_connections[bot_id]:
del active_connections[bot_id]
finally:
db.close()
@router.websocket("/ws/stats")
async def websocket_stats(websocket: WebSocket):
"""
WebSocket endpoint for streaming system statistics in real-time.
Args:
websocket: WebSocket connection
"""
await websocket.accept()
logger.info("WebSocket connection established for stats")
stats_connections.add(websocket)
try:
# Send stats every second
while True:
try:
# Get system stats
db: Session = next(get_db())
try:
from app.models.bot import BotStatus
system_stats = StatsCollector.get_system_stats()
bot_stats = StatsCollector.get_all_bots_stats()
# Get bot counts
bots_total = db.query(Bot).count()
bots_running = db.query(Bot).filter(Bot.status == BotStatus.RUNNING).count()
bots_stopped = db.query(Bot).filter(Bot.status == BotStatus.STOPPED).count()
bots_crashed = db.query(Bot).filter(Bot.status == BotStatus.CRASHED).count()
stats_data = {
"timestamp": datetime.utcnow().isoformat(),
"system": {
**system_stats,
"bots_total": bots_total,
"bots_running": bots_running,
"bots_stopped": bots_stopped,
"bots_crashed": bots_crashed,
},
"bots": bot_stats,
}
await websocket.send_json(stats_data)
finally:
db.close()
await asyncio.sleep(1)
except WebSocketDisconnect:
break
except Exception as e:
logger.error(f"Error streaming stats: {e}")
break
except Exception as e:
logger.error(f"WebSocket error for stats: {e}")
finally:
stats_connections.discard(websocket)
logger.info("WebSocket disconnected for stats")
async def broadcast_log(bot_id: str, log_data: dict):
"""
Broadcast a log message to all connected clients for a bot.
Args:
bot_id: Bot ID
log_data: Log data to broadcast
"""
if bot_id in active_connections:
disconnected = set()
for websocket in active_connections[bot_id]:
try:
await websocket.send_json(log_data)
except Exception:
disconnected.add(websocket)
# Clean up disconnected clients
active_connections[bot_id] -= disconnected
if not active_connections[bot_id]:
del active_connections[bot_id]

View File

@@ -0,0 +1,29 @@
"""Pydantic schemas for request/response validation."""
from app.schemas.bot import (
BotCreate,
BotUpdate,
BotResponse,
BotListResponse,
BotStatusResponse,
)
from app.schemas.stats import SystemStats, BotStats, AggregateStats
from app.schemas.log import LogEntryResponse, LogListResponse
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
__all__ = [
"BotCreate",
"BotUpdate",
"BotResponse",
"BotListResponse",
"BotStatusResponse",
"SystemStats",
"BotStats",
"AggregateStats",
"LogEntryResponse",
"LogListResponse",
"UserCreate",
"UserLogin",
"UserResponse",
"Token",
]

View File

@@ -0,0 +1,81 @@
"""Pydantic schemas for bot operations."""
from typing import Optional, Dict, Any, List
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.models.bot import BotType, BotStatus
class BotCreate(BaseModel):
"""Schema for creating a new bot."""
name: str = Field(..., min_length=1, max_length=100, description="Unique bot name")
type: BotType = Field(..., description="Bot type (telegram_userbot, telegram_bot, discord_bot)")
config: Dict[str, Any] = Field(..., description="Bot configuration (tokens, settings)")
auto_restart: bool = Field(True, description="Enable automatic restart on crash")
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "My Telegram Bot",
"type": "telegram_bot",
"config": {
"token": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"admin_user_ids": [12345678]
},
"auto_restart": True
}
}
)
class BotUpdate(BaseModel):
"""Schema for updating bot configuration."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
config: Optional[Dict[str, Any]] = None
auto_restart: Optional[bool] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Bot Name",
"auto_restart": False
}
}
)
class BotResponse(BaseModel):
"""Schema for bot response."""
id: str
name: str
type: BotType
config: Dict[str, Any]
status: BotStatus
auto_restart: bool
created_at: datetime
updated_at: datetime
last_started_at: Optional[datetime]
process_id: Optional[int]
restart_count: int
last_crash_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class BotListResponse(BaseModel):
"""Schema for paginated bot list."""
total: int
page: int
page_size: int
bots: List[BotResponse]
class BotStatusResponse(BaseModel):
"""Schema for bot status information."""
id: str
name: str
status: BotStatus
process_id: Optional[int]
uptime_seconds: Optional[int]
last_started_at: Optional[datetime]

View File

@@ -0,0 +1,26 @@
"""Pydantic schemas for log entries."""
from typing import List
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.log import LogLevel
class LogEntryResponse(BaseModel):
"""Schema for log entry response."""
id: int
bot_id: str
timestamp: datetime
level: LogLevel
message: str
model_config = ConfigDict(from_attributes=True)
class LogListResponse(BaseModel):
"""Schema for paginated log list."""
total: int
page: int
page_size: int
logs: List[LogEntryResponse]

View File

@@ -0,0 +1,40 @@
"""Pydantic schemas for statistics."""
from typing import Optional, List
from pydantic import BaseModel, Field
class SystemStats(BaseModel):
"""Schema for system-wide statistics."""
cpu_percent: float = Field(..., description="Overall CPU usage percentage")
ram_used_mb: float = Field(..., description="RAM used in megabytes")
ram_total_mb: float = Field(..., description="Total RAM in megabytes")
ram_percent: float = Field(..., description="RAM usage percentage")
disk_used_gb: float = Field(..., description="Disk used in gigabytes")
disk_total_gb: float = Field(..., description="Total disk in gigabytes")
disk_percent: float = Field(..., description="Disk usage percentage")
network_sent_mb: float = Field(..., description="Network data sent in MB")
network_recv_mb: float = Field(..., description="Network data received in MB")
bots_total: int = Field(..., description="Total number of bots")
bots_running: int = Field(..., description="Number of running bots")
bots_stopped: int = Field(..., description="Number of stopped bots")
bots_crashed: int = Field(..., description="Number of crashed bots")
class BotStats(BaseModel):
"""Schema for individual bot statistics."""
bot_id: str
bot_name: str
cpu_percent: Optional[float] = Field(None, description="Bot CPU usage percentage")
ram_mb: Optional[float] = Field(None, description="Bot RAM usage in MB")
uptime_seconds: Optional[int] = Field(None, description="Bot uptime in seconds")
status: str
class AggregateStats(BaseModel):
"""Schema for aggregate bot statistics."""
total_bots: int
total_cpu_percent: float
total_ram_mb: float
average_uptime_seconds: Optional[float]
bot_stats: List[BotStats]

View File

@@ -0,0 +1,42 @@
"""Pydantic schemas for user authentication."""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class UserCreate(BaseModel):
"""Schema for user registration."""
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
password: str = Field(..., min_length=8, max_length=100)
class UserLogin(BaseModel):
"""Schema for user login."""
username: str
password: str
class UserResponse(BaseModel):
"""Schema for user response."""
id: str
username: str
email: str
is_active: bool
is_admin: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
"""Schema for JWT token response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Schema for token payload data."""
user_id: str
username: str

View File

@@ -0,0 +1,14 @@
"""Service layer for business logic."""
from app.services.bot_manager import BotManager, bot_manager
from app.services.process_manager import ProcessManager
from app.services.log_collector import LogCollector
from app.services.stats_collector import StatsCollector
__all__ = [
"BotManager",
"bot_manager",
"ProcessManager",
"LogCollector",
"StatsCollector",
]

View File

@@ -0,0 +1,325 @@
"""Bot management service - singleton orchestrator for all bots."""
import asyncio
from typing import Dict, Optional, List
from datetime import datetime
from threading import Thread, Lock
import time
from sqlalchemy.orm import Session
from app.services.process_manager import ProcessManager
from app.models.bot import Bot, BotStatus
from app.utils.logger import setup_logger
from app.config import settings
logger = setup_logger(__name__)
class BotManager:
"""
Singleton manager for all bot processes.
Handles bot lifecycle, monitoring, and crash recovery.
"""
_instance: Optional["BotManager"] = None
_lock: Lock = Lock()
def __new__(cls):
"""Ensure singleton instance."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize bot manager."""
if hasattr(self, "_initialized"):
return
self.processes: Dict[str, ProcessManager] = {}
self.last_crash_time: Dict[str, float] = {}
self.monitor_thread: Optional[Thread] = None
self.running = False
self._initialized = True
logger.info("BotManager initialized")
def start_monitoring(self) -> None:
"""Start background monitoring thread."""
if self.running:
logger.warning("Monitoring already running")
return
self.running = True
self.monitor_thread = Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
logger.info("Started bot monitoring thread")
def stop_monitoring(self) -> None:
"""Stop background monitoring thread."""
self.running = False
if self.monitor_thread:
self.monitor_thread.join(timeout=5)
logger.info("Stopped bot monitoring thread")
def load_bots_from_db(self, db: Session) -> None:
"""
Load and start bots from database that were running.
Args:
db: Database session
"""
try:
running_bots = db.query(Bot).filter(
Bot.status.in_([BotStatus.RUNNING, BotStatus.STARTING])
).all()
for bot in running_bots:
logger.info(f"Restoring bot {bot.name} from database")
# Reset status to stopped, then start if auto_restart is enabled
bot.status = BotStatus.STOPPED
db.commit()
if bot.auto_restart:
self.start_bot(bot.id, db)
except Exception as e:
logger.error(f"Error loading bots from database: {e}")
def start_bot(self, bot_id: str, db: Session) -> bool:
"""
Start a bot process.
Args:
bot_id: Bot ID to start
db: Database session
Returns:
True if started successfully, False otherwise
"""
try:
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
logger.error(f"Bot {bot_id} not found")
return False
if bot_id in self.processes and self.processes[bot_id].is_running():
logger.warning(f"Bot {bot.name} is already running")
return False
# Update status to starting
bot.status = BotStatus.STARTING
db.commit()
# Create and start process manager
process_manager = ProcessManager(
bot_id=bot.id,
bot_name=bot.name,
bot_type=bot.type.value,
config=bot.config
)
if process_manager.start():
self.processes[bot_id] = process_manager
# Update database
bot.status = BotStatus.RUNNING
bot.last_started_at = datetime.utcnow()
bot.process_id = process_manager.get_pid()
db.commit()
logger.info(f"Successfully started bot {bot.name}")
return True
else:
bot.status = BotStatus.CRASHED
db.commit()
logger.error(f"Failed to start bot {bot.name}")
return False
except Exception as e:
logger.error(f"Error starting bot {bot_id}: {e}")
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if bot:
bot.status = BotStatus.CRASHED
db.commit()
return False
def stop_bot(self, bot_id: str, db: Session) -> bool:
"""
Stop a bot process.
Args:
bot_id: Bot ID to stop
db: Database session
Returns:
True if stopped successfully, False otherwise
"""
try:
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
logger.error(f"Bot {bot_id} not found")
return False
if bot_id not in self.processes:
logger.warning(f"Bot {bot.name} process not found")
bot.status = BotStatus.STOPPED
bot.process_id = None
db.commit()
return True
# Update status to stopping
bot.status = BotStatus.STOPPING
db.commit()
# Stop the process
process_manager = self.processes[bot_id]
if process_manager.stop():
del self.processes[bot_id]
# Update database
bot.status = BotStatus.STOPPED
bot.process_id = None
db.commit()
logger.info(f"Successfully stopped bot {bot.name}")
return True
else:
logger.error(f"Failed to stop bot {bot.name}")
return False
except Exception as e:
logger.error(f"Error stopping bot {bot_id}: {e}")
return False
def restart_bot(self, bot_id: str, db: Session) -> bool:
"""
Restart a bot process.
Args:
bot_id: Bot ID to restart
db: Database session
Returns:
True if restarted successfully, False otherwise
"""
logger.info(f"Restarting bot {bot_id}")
self.stop_bot(bot_id, db)
time.sleep(1)
return self.start_bot(bot_id, db)
def get_bot_status(self, bot_id: str) -> Optional[Dict]:
"""
Get current status of a bot.
Args:
bot_id: Bot ID
Returns:
Dictionary with status information, or None if not found
"""
if bot_id not in self.processes:
return None
process_manager = self.processes[bot_id]
return {
"is_running": process_manager.is_running(),
"pid": process_manager.get_pid(),
"uptime": process_manager.get_uptime(),
"resources": process_manager.get_resource_usage(),
}
def get_all_bots_status(self) -> Dict[str, Dict]:
"""
Get status of all managed bots.
Returns:
Dictionary mapping bot IDs to status information
"""
return {
bot_id: self.get_bot_status(bot_id)
for bot_id in self.processes.keys()
}
def stop_all_bots(self, db: Session) -> None:
"""
Stop all running bots gracefully.
Args:
db: Database session
"""
logger.info("Stopping all bots...")
bot_ids = list(self.processes.keys())
for bot_id in bot_ids:
self.stop_bot(bot_id, db)
def _monitor_loop(self) -> None:
"""
Background monitoring loop.
Checks bot health and handles crash recovery.
"""
from app.database import SessionLocal
while self.running:
try:
db = SessionLocal()
try:
self._check_bot_health(db)
finally:
db.close()
except Exception as e:
logger.error(f"Error in monitoring loop: {e}")
time.sleep(settings.BOT_PROCESS_CHECK_INTERVAL)
def _check_bot_health(self, db: Session) -> None:
"""
Check health of all bots and handle crashes.
Args:
db: Database session
"""
for bot_id, process_manager in list(self.processes.items()):
try:
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
continue
# Check if process is still running
if not process_manager.is_running():
logger.warning(f"Detected crashed bot: {bot.name}")
# Update database
bot.status = BotStatus.CRASHED
bot.process_id = None
bot.last_crash_at = datetime.utcnow()
bot.restart_count += 1
db.commit()
# Remove from processes
del self.processes[bot_id]
# Auto-restart if enabled
if bot.auto_restart and settings.AUTO_RESTART_BOTS:
# Check backoff period
last_crash = self.last_crash_time.get(bot_id, 0)
time_since_crash = time.time() - last_crash
if time_since_crash > settings.BOT_RESTART_BACKOFF_SECONDS:
logger.info(f"Auto-restarting bot {bot.name}")
self.last_crash_time[bot_id] = time.time()
self.start_bot(bot_id, db)
else:
logger.info(
f"Waiting for backoff period before restarting {bot.name}"
)
except Exception as e:
logger.error(f"Error checking health of bot {bot_id}: {e}")
# Global singleton instance
bot_manager = BotManager()

View File

@@ -0,0 +1,158 @@
"""Log collection service for bot logs."""
import os
from typing import List, Optional, AsyncIterator
from collections import deque
import asyncio
import aiofiles
from app.config import settings
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
class LogCollector:
"""
Collects and streams bot logs.
Maintains a buffer of recent logs and can stream new logs in real-time.
"""
def __init__(self, bot_id: str, buffer_size: int = 100):
"""
Initialize log collector.
Args:
bot_id: Bot ID to collect logs for
buffer_size: Number of recent log lines to buffer
"""
self.bot_id = bot_id
self.buffer_size = buffer_size
self.log_buffer: deque = deque(maxlen=buffer_size)
self.log_path = os.path.join(settings.LOGS_DIR, f"{bot_id}.log")
self.subscribers: List[asyncio.Queue] = []
self._load_recent_logs()
def _load_recent_logs(self) -> None:
"""Load recent logs from file into buffer."""
try:
if os.path.exists(self.log_path):
with open(self.log_path, "r") as f:
# Read last N lines
lines = deque(f, maxlen=self.buffer_size)
self.log_buffer.extend(lines)
logger.info(f"Loaded {len(self.log_buffer)} recent log lines for bot {self.bot_id}")
except Exception as e:
logger.error(f"Error loading recent logs for bot {self.bot_id}: {e}")
def get_buffered_logs(self) -> List[str]:
"""
Get buffered log lines.
Returns:
List of recent log lines
"""
return list(self.log_buffer)
async def read_logs(
self,
offset: int = 0,
limit: int = 100
) -> List[str]:
"""
Read logs from file with pagination.
Args:
offset: Number of lines to skip from start
limit: Maximum number of lines to return
Returns:
List of log lines
"""
try:
if not os.path.exists(self.log_path):
return []
async with aiofiles.open(self.log_path, "r") as f:
lines = await f.readlines()
return lines[offset:offset + limit]
except Exception as e:
logger.error(f"Error reading logs for bot {self.bot_id}: {e}")
return []
async def stream_logs(self) -> AsyncIterator[str]:
"""
Stream new log lines as they arrive.
Yields:
New log lines
"""
queue: asyncio.Queue = asyncio.Queue()
self.subscribers.append(queue)
try:
while True:
line = await queue.get()
yield line
finally:
self.subscribers.remove(queue)
async def publish_log(self, line: str) -> None:
"""
Publish a new log line to all subscribers.
Args:
line: Log line to publish
"""
# Add to buffer
self.log_buffer.append(line)
# Notify all subscribers
for queue in self.subscribers:
try:
await queue.put(line)
except Exception as e:
logger.error(f"Error publishing log to subscriber: {e}")
async def tail_logs(self, lines: int = 50) -> List[str]:
"""
Get the last N lines from log file.
Args:
lines: Number of lines to retrieve
Returns:
List of last N log lines
"""
try:
if not os.path.exists(self.log_path):
return []
async with aiofiles.open(self.log_path, "r") as f:
content = await f.read()
all_lines = content.splitlines()
return all_lines[-lines:] if len(all_lines) > lines else all_lines
except Exception as e:
logger.error(f"Error tailing logs for bot {self.bot_id}: {e}")
return []
def clear_logs(self) -> bool:
"""
Clear log file for this bot.
Returns:
True if successful, False otherwise
"""
try:
if os.path.exists(self.log_path):
open(self.log_path, "w").close()
self.log_buffer.clear()
logger.info(f"Cleared logs for bot {self.bot_id}")
return True
return False
except Exception as e:
logger.error(f"Error clearing logs for bot {self.bot_id}: {e}")
return False

View File

@@ -0,0 +1,260 @@
"""Process management for bot subprocesses."""
import subprocess
import signal
import time
import psutil
from typing import Optional, Dict, Any, IO
from threading import Thread
import os
from app.utils.logger import setup_logger
from app.config import settings
logger = setup_logger(__name__)
class ProcessManager:
"""
Manages individual bot processes.
Handles process lifecycle, monitoring, and resource tracking.
"""
def __init__(self, bot_id: str, bot_name: str, bot_type: str, config: Dict[str, Any]):
"""
Initialize process manager.
Args:
bot_id: Unique bot identifier
bot_name: Bot name for logging
bot_type: Type of bot (telegram_userbot, telegram_bot, discord_bot)
config: Bot configuration dictionary
"""
self.bot_id = bot_id
self.bot_name = bot_name
self.bot_type = bot_type
self.config = config
self.process: Optional[subprocess.Popen] = None
self.start_time: Optional[float] = None
self.stdout_thread: Optional[Thread] = None
self.stderr_thread: Optional[Thread] = None
self.log_file: Optional[IO] = None
# Ensure logs directory exists
os.makedirs(settings.LOGS_DIR, exist_ok=True)
self.log_path = os.path.join(settings.LOGS_DIR, f"{bot_id}.log")
def start(self) -> bool:
"""
Start the bot process.
Returns:
True if process started successfully, False otherwise
"""
if self.is_running():
logger.warning(f"Bot {self.bot_name} is already running")
return False
try:
# Prepare environment variables
env = os.environ.copy()
env.update({
"BOT_ID": self.bot_id,
"BOT_TYPE": self.bot_type,
"BOT_NAME": self.bot_name,
})
# Add bot-specific config to environment
for key, value in self.config.items():
env[key.upper()] = str(value)
# Determine the script path based on bot type
script_map = {
"telegram_userbot": "telegram_userbot.py",
"telegram_bot": "telegram_bot.py",
"discord_bot": "discord_bot.py",
}
script_name = script_map.get(self.bot_type)
if not script_name:
logger.error(f"Unknown bot type: {self.bot_type}")
return False
script_path = os.path.join(settings.BOTS_DIR, "examples", script_name)
# Open log file
self.log_file = open(self.log_path, "a", buffering=1)
# Start the process
self.process = subprocess.Popen(
["python3", script_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
cwd=settings.BOTS_DIR,
text=True,
bufsize=1
)
self.start_time = time.time()
# Start output capture threads
self.stdout_thread = Thread(
target=self._capture_output,
args=(self.process.stdout, "INFO"),
daemon=True
)
self.stderr_thread = Thread(
target=self._capture_output,
args=(self.process.stderr, "ERROR"),
daemon=True
)
self.stdout_thread.start()
self.stderr_thread.start()
logger.info(f"Started bot {self.bot_name} with PID {self.process.pid}")
return True
except Exception as e:
logger.error(f"Failed to start bot {self.bot_name}: {e}")
self._cleanup()
return False
def stop(self, force: bool = False) -> bool:
"""
Stop the bot process.
Args:
force: If True, send SIGKILL instead of SIGTERM
Returns:
True if process stopped successfully, False otherwise
"""
if not self.is_running():
logger.warning(f"Bot {self.bot_name} is not running")
return False
try:
if force:
self.process.kill()
logger.info(f"Forcefully killed bot {self.bot_name}")
else:
self.process.terminate()
logger.info(f"Sent termination signal to bot {self.bot_name}")
# Wait for graceful shutdown
try:
self.process.wait(timeout=settings.BOT_SHUTDOWN_TIMEOUT)
except subprocess.TimeoutExpired:
logger.warning(f"Bot {self.bot_name} did not stop gracefully, forcing...")
self.process.kill()
self._cleanup()
return True
except Exception as e:
logger.error(f"Failed to stop bot {self.bot_name}: {e}")
return False
def restart(self) -> bool:
"""
Restart the bot process.
Returns:
True if restart successful, False otherwise
"""
logger.info(f"Restarting bot {self.bot_name}")
self.stop()
time.sleep(1) # Brief pause between stop and start
return self.start()
def is_running(self) -> bool:
"""
Check if process is currently running.
Returns:
True if process is running, False otherwise
"""
if self.process is None:
return False
return self.process.poll() is None
def get_pid(self) -> Optional[int]:
"""
Get process ID.
Returns:
Process ID if running, None otherwise
"""
if self.is_running():
return self.process.pid
return None
def get_uptime(self) -> Optional[int]:
"""
Get process uptime in seconds.
Returns:
Uptime in seconds if running, None otherwise
"""
if self.is_running() and self.start_time:
return int(time.time() - self.start_time)
return None
def get_resource_usage(self) -> Optional[Dict[str, float]]:
"""
Get process resource usage (CPU, RAM).
Returns:
Dictionary with cpu_percent and ram_mb, or None if not running
"""
if not self.is_running():
return None
try:
process = psutil.Process(self.process.pid)
return {
"cpu_percent": process.cpu_percent(interval=0.1),
"ram_mb": process.memory_info().rss / 1024 / 1024,
}
except (psutil.NoSuchProcess, psutil.AccessDenied):
return None
def _capture_output(self, pipe: IO, level: str) -> None:
"""
Capture output from stdout/stderr pipe.
Args:
pipe: Pipe to read from
level: Log level for output
"""
try:
for line in iter(pipe.readline, ""):
if line:
# Write to log file
if self.log_file and not self.log_file.closed:
self.log_file.write(f"[{level}] {line}")
# Log to console
if level == "ERROR":
logger.error(f"[{self.bot_name}] {line.strip()}")
else:
logger.info(f"[{self.bot_name}] {line.strip()}")
except Exception as e:
logger.error(f"Error capturing output for {self.bot_name}: {e}")
finally:
pipe.close()
def _cleanup(self) -> None:
"""Clean up resources after process stops."""
self.process = None
self.start_time = None
if self.log_file and not self.log_file.closed:
self.log_file.close()
self.log_file = None
def __del__(self):
"""Cleanup on deletion."""
if self.is_running():
self.stop(force=True)

View File

@@ -0,0 +1,141 @@
"""Statistics collection service for system and bot metrics."""
import psutil
from typing import Dict, Any, List, Optional
from app.services.bot_manager import bot_manager
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
class StatsCollector:
"""
Collects system and bot statistics.
Provides metrics like CPU, RAM, disk, network usage.
"""
@staticmethod
def get_system_stats() -> Dict[str, Any]:
"""
Get overall system statistics.
Returns:
Dictionary with system metrics
"""
try:
# CPU
cpu_percent = psutil.cpu_percent(interval=1)
# Memory
memory = psutil.virtual_memory()
ram_used_mb = memory.used / 1024 / 1024
ram_total_mb = memory.total / 1024 / 1024
ram_percent = memory.percent
# Disk
disk = psutil.disk_usage("/")
disk_used_gb = disk.used / 1024 / 1024 / 1024
disk_total_gb = disk.total / 1024 / 1024 / 1024
disk_percent = disk.percent
# Network
network = psutil.net_io_counters()
network_sent_mb = network.bytes_sent / 1024 / 1024
network_recv_mb = network.bytes_recv / 1024 / 1024
return {
"cpu_percent": round(cpu_percent, 2),
"ram_used_mb": round(ram_used_mb, 2),
"ram_total_mb": round(ram_total_mb, 2),
"ram_percent": round(ram_percent, 2),
"disk_used_gb": round(disk_used_gb, 2),
"disk_total_gb": round(disk_total_gb, 2),
"disk_percent": round(disk_percent, 2),
"network_sent_mb": round(network_sent_mb, 2),
"network_recv_mb": round(network_recv_mb, 2),
}
except Exception as e:
logger.error(f"Error collecting system stats: {e}")
return {}
@staticmethod
def get_bot_stats(bot_id: str) -> Optional[Dict[str, Any]]:
"""
Get statistics for a specific bot.
Args:
bot_id: Bot ID
Returns:
Dictionary with bot metrics, or None if bot not found
"""
try:
status = bot_manager.get_bot_status(bot_id)
if not status or not status["is_running"]:
return None
resources = status.get("resources", {})
return {
"bot_id": bot_id,
"cpu_percent": round(resources.get("cpu_percent", 0), 2),
"ram_mb": round(resources.get("ram_mb", 0), 2),
"uptime_seconds": status.get("uptime"),
}
except Exception as e:
logger.error(f"Error collecting stats for bot {bot_id}: {e}")
return None
@staticmethod
def get_all_bots_stats() -> List[Dict[str, Any]]:
"""
Get statistics for all bots.
Returns:
List of bot statistics dictionaries
"""
all_status = bot_manager.get_all_bots_status()
stats = []
for bot_id, status in all_status.items():
if status and status["is_running"]:
bot_stat = StatsCollector.get_bot_stats(bot_id)
if bot_stat:
stats.append(bot_stat)
return stats
@staticmethod
def get_aggregate_stats(bot_stats: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Get aggregate statistics across all bots.
Args:
bot_stats: List of individual bot statistics
Returns:
Dictionary with aggregate metrics
"""
if not bot_stats:
return {
"total_bots": 0,
"total_cpu_percent": 0,
"total_ram_mb": 0,
"average_uptime_seconds": None,
}
total_cpu = sum(stat.get("cpu_percent", 0) for stat in bot_stats)
total_ram = sum(stat.get("ram_mb", 0) for stat in bot_stats)
uptimes = [stat.get("uptime_seconds") for stat in bot_stats if stat.get("uptime_seconds")]
avg_uptime = sum(uptimes) / len(uptimes) if uptimes else None
return {
"total_bots": len(bot_stats),
"total_cpu_percent": round(total_cpu, 2),
"total_ram_mb": round(total_ram, 2),
"average_uptime_seconds": round(avg_uptime, 2) if avg_uptime else None,
}

View File

@@ -0,0 +1,19 @@
"""Utility functions and helpers."""
from app.utils.logger import setup_logger
from app.utils.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
verify_token,
)
__all__ = [
"setup_logger",
"verify_password",
"get_password_hash",
"create_access_token",
"create_refresh_token",
"verify_token",
]

View File

@@ -0,0 +1,60 @@
"""Logging configuration and setup."""
import logging
import sys
from typing import Optional
from app.config import settings
def setup_logger(
name: str,
level: Optional[str] = None,
log_file: Optional[str] = None
) -> logging.Logger:
"""
Set up a logger with consistent formatting.
Args:
name: Logger name
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional file path to write logs
Returns:
Configured logger instance
"""
logger = logging.getLogger(name)
log_level = getattr(logging, level or settings.LOG_LEVEL, logging.INFO)
logger.setLevel(log_level)
# Remove existing handlers
logger.handlers.clear()
# Create formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# File handler (optional)
if log_file:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler(
log_file,
maxBytes=settings.MAX_LOG_SIZE_MB * 1024 * 1024,
backupCount=10
)
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Prevent propagation to root logger
logger.propagate = False
return logger

View File

@@ -0,0 +1,92 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hashed password.
Args:
plain_password: Plain text password
hashed_password: Hashed password from database
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any]) -> str:
"""
Create a JWT access token.
Args:
data: Dictionary of claims to encode in token
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def create_refresh_token(data: Dict[str, Any]) -> str:
"""
Create a JWT refresh token with longer expiration.
Args:
data: Dictionary of claims to encode in token
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> Optional[Dict[str, Any]]:
"""
Verify and decode a JWT token.
Args:
token: JWT token string
token_type: Expected token type ("access" or "refresh")
Returns:
Decoded token payload if valid, None otherwise
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
if payload.get("type") != token_type:
return None
return payload
except JWTError:
return None

View File

@@ -0,0 +1,8 @@
-r requirements.txt
pytest==8.0.0
pytest-asyncio==0.23.5
pytest-cov==4.1.0
black==24.2.0
flake8==7.0.0
mypy==1.8.0
httpx==0.26.0

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
alembic==1.13.1
pydantic==2.6.1
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
psutil==5.9.8
aiosqlite==0.19.0
websockets==12.0
python-dotenv==1.0.1

View File

@@ -0,0 +1,168 @@
"""Example Discord Bot using discord.py."""
import sys
import os
import logging
import discord
from discord.ext import commands
# Configure logging to stdout for dashboard capture
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
class MyBot(commands.Bot):
"""Custom bot class with event handlers."""
async def on_ready(self):
"""Called when bot is ready."""
logger.info(f"✅ Logged in as {self.user.name} (ID: {self.user.id})")
logger.info(f"Connected to {len(self.guilds)} guild(s)")
logger.info("Bot is ready!")
# Set bot status
await self.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching,
name="Bot Dashboard"
)
)
async def on_command_error(self, ctx, error):
"""Handle command errors."""
if isinstance(error, commands.CommandNotFound):
await ctx.send("❌ Unknown command. Use `!help` to see available commands.")
elif isinstance(error, commands.MissingRequiredArgument):
await ctx.send(f"❌ Missing required argument: {error.param.name}")
else:
logger.error(f"Command error: {error}", exc_info=error)
await ctx.send(f"❌ An error occurred: {str(error)}")
async def on_message(self, message):
"""Handle incoming messages."""
# Ignore bot's own messages
if message.author == self.user:
return
# Log messages (optional)
if not message.content.startswith(self.command_prefix):
logger.debug(f"Message from {message.author}: {message.content[:50]}")
# Process commands
await self.process_commands(message)
def main():
"""Main bot function."""
# Load configuration from environment variables
token = os.getenv("TOKEN") or os.getenv("DISCORD_TOKEN")
prefix = os.getenv("COMMAND_PREFIX") or os.getenv("PREFIX", "!")
bot_name = os.getenv("BOT_NAME", "Discord Bot")
if not token:
logger.error("TOKEN or DISCORD_TOKEN environment variable is required!")
sys.exit(1)
logger.info(f"Starting {bot_name}...")
logger.info(f"Command prefix: {prefix}")
# Configure intents
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# Create bot instance
bot = MyBot(command_prefix=prefix, intents=intents)
@bot.command(name="ping")
async def ping(ctx):
"""Check bot latency."""
latency = round(bot.latency * 1000)
await ctx.send(f"🏓 Pong! Latency: {latency}ms")
logger.info(f"Ping command from {ctx.author} - Latency: {latency}ms")
@bot.command(name="status")
async def status(ctx):
"""Check bot status."""
embed = discord.Embed(
title="✅ Bot Status",
description="Bot is online and operational!",
color=discord.Color.green()
)
embed.add_field(name="Servers", value=len(bot.guilds), inline=True)
embed.add_field(name="Latency", value=f"{round(bot.latency * 1000)}ms", inline=True)
await ctx.send(embed=embed)
logger.info(f"Status command from {ctx.author}")
@bot.command(name="echo")
async def echo(ctx, *, text: str):
"""Echo back the provided text."""
await ctx.send(text)
logger.info(f"Echo command from {ctx.author}: {text[:50]}")
@bot.command(name="info")
async def info(ctx):
"""Get user information."""
user = ctx.author
embed = discord.Embed(
title="👤 User Information",
color=discord.Color.blue()
)
embed.add_field(name="Username", value=str(user), inline=True)
embed.add_field(name="ID", value=user.id, inline=True)
embed.add_field(name="Nickname", value=user.display_name, inline=True)
embed.add_field(name="Account Created", value=user.created_at.strftime("%Y-%m-%d"), inline=True)
if isinstance(user, discord.Member):
embed.add_field(name="Joined Server", value=user.joined_at.strftime("%Y-%m-%d"), inline=True)
embed.add_field(name="Roles", value=len(user.roles) - 1, inline=True)
embed.set_thumbnail(url=user.display_avatar.url)
await ctx.send(embed=embed)
logger.info(f"Info command from {user}")
@bot.command(name="serverinfo")
async def serverinfo(ctx):
"""Get server information."""
guild = ctx.guild
if not guild:
await ctx.send("This command can only be used in a server.")
return
embed = discord.Embed(
title=f"🏰 {guild.name}",
description=guild.description or "No description",
color=discord.Color.purple()
)
embed.add_field(name="Server ID", value=guild.id, inline=True)
embed.add_field(name="Owner", value=guild.owner.mention if guild.owner else "Unknown", inline=True)
embed.add_field(name="Members", value=guild.member_count, inline=True)
embed.add_field(name="Channels", value=len(guild.channels), inline=True)
embed.add_field(name="Roles", value=len(guild.roles), inline=True)
embed.add_field(name="Created", value=guild.created_at.strftime("%Y-%m-%d"), inline=True)
if guild.icon:
embed.set_thumbnail(url=guild.icon.url)
await ctx.send(embed=embed)
logger.info(f"Serverinfo command from {ctx.author} in {guild.name}")
@bot.command(name="hello")
async def hello(ctx):
"""Say hello."""
await ctx.send(f"👋 Hello {ctx.author.mention}!")
logger.info(f"Hello command from {ctx.author}")
try:
# Run the bot
bot.run(token)
except KeyboardInterrupt:
logger.info("Received stop signal, shutting down...")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
telethon==1.34.0
python-telegram-bot==20.8
discord.py==2.3.2

View File

@@ -0,0 +1,150 @@
"""Example Telegram Bot using python-telegram-bot."""
import sys
import os
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
# Configure logging to stdout for dashboard capture
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /start command."""
try:
user = update.effective_user
welcome_text = (
f"👋 Hello {user.mention_html()}!\n\n"
f"I'm a bot managed by the Bot Management Dashboard.\n\n"
f"Use /help to see available commands."
)
await update.message.reply_html(welcome_text)
logger.info(f"Start command from user {user.id} (@{user.username})")
except Exception as e:
logger.error(f"Error in start command: {e}")
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /help command."""
try:
help_text = (
"📚 **Available Commands:**\n\n"
"/start - Start the bot\n"
"/help - Show this help message\n"
"/status - Check bot status\n"
"/ping - Check bot response time\n"
"/echo <text> - Echo back your message\n"
"/info - Get your user information"
)
await update.message.reply_text(help_text)
logger.info(f"Help command from user {update.effective_user.id}")
except Exception as e:
logger.error(f"Error in help command: {e}")
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /status command."""
try:
status_text = "✅ Bot is online and operational!"
await update.message.reply_text(status_text)
logger.info(f"Status command from user {update.effective_user.id}")
except Exception as e:
logger.error(f"Error in status command: {e}")
async def ping_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /ping command."""
try:
await update.message.reply_text("🏓 Pong!")
logger.info(f"Ping command from user {update.effective_user.id}")
except Exception as e:
logger.error(f"Error in ping command: {e}")
async def echo_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /echo command."""
try:
if context.args:
text = " ".join(context.args)
await update.message.reply_text(text)
logger.info(f"Echo command from user {update.effective_user.id}: {text[:50]}")
else:
await update.message.reply_text("Please provide text to echo. Usage: /echo <text>")
except Exception as e:
logger.error(f"Error in echo command: {e}")
async def info_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /info command."""
try:
user = update.effective_user
info_text = (
f"👤 **Your Information:**\n\n"
f"ID: `{user.id}`\n"
f"First Name: {user.first_name}\n"
f"Last Name: {user.last_name if user.last_name else 'N/A'}\n"
f"Username: @{user.username if user.username else 'N/A'}\n"
f"Language: {user.language_code if user.language_code else 'N/A'}"
)
await update.message.reply_text(info_text)
logger.info(f"Info command from user {user.id}")
except Exception as e:
logger.error(f"Error in info command: {e}")
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
"""Handle errors."""
logger.error(f"Update {update} caused error: {context.error}", exc_info=context.error)
def main():
"""Main bot function."""
# Load configuration from environment variables
token = os.getenv("TOKEN") or os.getenv("BOT_TOKEN")
bot_name = os.getenv("BOT_NAME", "Telegram Bot")
if not token:
logger.error("TOKEN or BOT_TOKEN environment variable is required!")
sys.exit(1)
logger.info(f"Starting {bot_name}...")
try:
# Create application
application = Application.builder().token(token).build()
# Register command handlers
application.add_handler(CommandHandler("start", start_command))
application.add_handler(CommandHandler("help", help_command))
application.add_handler(CommandHandler("status", status_command))
application.add_handler(CommandHandler("ping", ping_command))
application.add_handler(CommandHandler("echo", echo_command))
application.add_handler(CommandHandler("info", info_command))
# Register error handler
application.add_error_handler(error_handler)
logger.info(f"{bot_name} started successfully!")
logger.info("Bot is now running. Press Ctrl+C to stop.")
# Start the bot
application.run_polling(allowed_updates=Update.ALL_TYPES)
except KeyboardInterrupt:
logger.info("Received stop signal, shutting down...")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
finally:
logger.info("Bot stopped.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,117 @@
"""Example Telegram Userbot using Telethon."""
import sys
import os
import logging
import asyncio
from telethon import TelegramClient, events
# Configure logging to stdout for dashboard capture
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
async def main():
"""Main userbot function."""
# Load configuration from environment variables
api_id = os.getenv("API_ID")
api_hash = os.getenv("API_HASH")
phone = os.getenv("PHONE")
session_name = os.getenv("SESSION_NAME", "userbot_session")
bot_name = os.getenv("BOT_NAME", "Telegram Userbot")
if not api_id or not api_hash:
logger.error("API_ID and API_HASH are required!")
sys.exit(1)
logger.info(f"Starting {bot_name}...")
logger.info(f"Using session: {session_name}")
try:
# Create Telegram client
client = TelegramClient(session_name, int(api_id), api_hash)
@client.on(events.NewMessage(pattern=r"^\.ping$"))
async def ping_handler(event):
"""Handle .ping command."""
try:
await event.reply("🏓 Pong!")
logger.info(f"Replied to ping from {event.sender_id}")
except Exception as e:
logger.error(f"Error handling ping: {e}")
@client.on(events.NewMessage(pattern=r"^\.echo (.+)"))
async def echo_handler(event):
"""Handle .echo command."""
try:
text = event.pattern_match.group(1)
await event.reply(text)
logger.info(f"Echoed: {text[:50]}")
except Exception as e:
logger.error(f"Error handling echo: {e}")
@client.on(events.NewMessage(pattern=r"^\.info$"))
async def info_handler(event):
"""Handle .info command."""
try:
me = await client.get_me()
info_text = (
f"👤 **User Info**\n"
f"ID: `{me.id}`\n"
f"Name: {me.first_name or ''} {me.last_name or ''}\n"
f"Username: @{me.username if me.username else 'N/A'}\n"
f"Phone: {me.phone if me.phone else 'N/A'}"
)
await event.reply(info_text)
logger.info("Sent user info")
except Exception as e:
logger.error(f"Error handling info: {e}")
@client.on(events.NewMessage(pattern=r"^\.help$"))
async def help_handler(event):
"""Handle .help command."""
try:
help_text = (
"📚 **Available Commands**\n\n"
"`.ping` - Check if bot is alive\n"
"`.echo <text>` - Echo back the text\n"
"`.info` - Show your user info\n"
"`.help` - Show this help message"
)
await event.reply(help_text)
logger.info("Sent help message")
except Exception as e:
logger.error(f"Error handling help: {e}")
# Start the client
await client.start(phone=phone)
logger.info(f"{bot_name} started successfully!")
me = await client.get_me()
logger.info(f"Logged in as: {me.first_name} (@{me.username if me.username else me.id})")
# Keep the client running
logger.info("Userbot is now running. Press Ctrl+C to stop.")
await client.run_until_disconnected()
except KeyboardInterrupt:
logger.info("Received stop signal, shutting down...")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
finally:
if "client" in locals():
await client.disconnect()
logger.info("Userbot stopped.")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

61
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,61 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: bot-dashboard-backend
volumes:
- ./bots:/app/bots
- bot-data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/bot_dashboard.db
- SECRET_KEY=${SECRET_KEY}
- CORS_ORIGINS=${CORS_ORIGINS:-https://yourdomain.com}
- LOG_LEVEL=INFO
- AUTO_RESTART_BOTS=true
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: bot-dashboard-frontend
environment:
- NEXT_PUBLIC_API_URL=${API_URL:-https://yourdomain.com}
- NEXT_PUBLIC_WS_URL=${WS_URL:-wss://yourdomain.com}
depends_on:
backend:
condition: service_healthy
restart: always
nginx:
image: nginx:alpine
container_name: bot-dashboard-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx-cache:/var/cache/nginx
depends_on:
- frontend
- backend
restart: always
volumes:
bot-data:
driver: local
nginx-cache:
driver: local
networks:
default:
name: bot-dashboard-network

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: bot-dashboard-backend
ports:
- "8000:8000"
volumes:
- ./bots:/app/bots
- ./backend/app:/app/app
- bot-data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/bot_dashboard.db
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
- LOG_LEVEL=INFO
- AUTO_RESTART_BOTS=true
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: bot-dashboard-frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
volumes:
bot-data:
driver: local
networks:
default:
name: bot-dashboard-network

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000

3
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

49
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Multi-stage build for Next.js frontend
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

125
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,125 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 263 70% 50%;
--primary-foreground: 210 20% 98%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 263 70% 50%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-900;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-600;
}
/* Glassmorphism effect */
.glass {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
}
/* Animations */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 3s ease infinite;
}
/* Status indicators */
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-running {
@apply bg-green-500;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.status-stopped {
@apply bg-gray-500;
}
.status-crashed {
@apply bg-red-500;
}
.status-starting {
@apply bg-yellow-500;
animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.status-stopping {
@apply bg-orange-500;
}

33
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
/**
* Root layout component.
*/
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Bot Management Dashboard",
description: "Manage your Telegram and Discord bots from a single interface",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={inter.className}>
<Providers>
{children}
<Toaster position="top-right" />
</Providers>
</body>
</html>
);
}

127
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,127 @@
/**
* Main dashboard page.
*/
"use client";
import { useState } from "react";
import { useBots } from "@/lib/hooks/useBots";
import { SystemStats } from "@/components/dashboard/SystemStats";
import { BotGrid } from "@/components/dashboard/BotGrid";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, RefreshCw } from "lucide-react";
import { BotStatus } from "@/types/bot";
export default function DashboardPage() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<BotStatus | undefined>();
const { data: botsData, isLoading, refetch } = useBots({
search: search || undefined,
status: statusFilter,
});
const handleRefresh = () => {
refetch();
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Bot Management Dashboard</h1>
<p className="text-sm text-muted-foreground">
Manage your Telegram and Discord bots
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Bot
</Button>
</div>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-6 space-y-6">
{/* System Stats */}
<section>
<SystemStats />
</section>
{/* Bots Section */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Your Bots</h2>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search bots..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 w-64"
/>
</div>
<select
value={statusFilter || ""}
onChange={(e) =>
setStatusFilter(e.target.value as BotStatus | undefined)
}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
<option value="crashed">Crashed</option>
</select>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="h-64 bg-card border border-border rounded-lg animate-pulse"
/>
))}
</div>
) : (
<BotGrid
bots={botsData?.bots || []}
onViewLogs={(bot) => {
// Navigate to logs page
window.location.href = `/bots/${bot.id}/logs`;
}}
/>
)}
{!isLoading && botsData && (
<div className="mt-4 text-sm text-muted-foreground text-center">
Showing {botsData.bots.length} of {botsData.total} bots
</div>
)}
</section>
</main>
{/* Footer */}
<footer className="border-t border-border mt-12">
<div className="container mx-auto px-4 py-6">
<p className="text-sm text-muted-foreground text-center">
Bot Management Dashboard v1.0.0
</p>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,28 @@
/**
* Providers wrapper for the application.
*/
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
refetchOnWindowFocus: false,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

16
frontend/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,192 @@
/**
* Bot card component displaying bot information and controls.
*/
"use client";
import { Bot } from "@/types/bot";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "./StatusBadge";
import {
Play,
Square,
RotateCw,
Trash2,
FileText,
Cpu,
MemoryStick,
Clock,
} from "lucide-react";
import { formatUptime, formatRelativeTime } from "@/lib/utils";
import { useStartBot, useStopBot, useRestartBot, useDeleteBot } from "@/lib/hooks/useBots";
import { useState } from "react";
interface BotCardProps {
bot: Bot;
onViewLogs?: (bot: Bot) => void;
}
export function BotCard({ bot, onViewLogs }: BotCardProps) {
const startBot = useStartBot();
const stopBot = useStopBot();
const restartBot = useRestartBot();
const deleteBot = useDeleteBot();
const [isDeleting, setIsDeleting] = useState(false);
const handleStart = () => {
startBot.mutate(bot.id);
};
const handleStop = () => {
stopBot.mutate(bot.id);
};
const handleRestart = () => {
restartBot.mutate(bot.id);
};
const handleDelete = () => {
if (window.confirm(`Are you sure you want to delete "${bot.name}"?`)) {
setIsDeleting(true);
deleteBot.mutate(bot.id, {
onSettled: () => setIsDeleting(false),
});
}
};
const isRunning = bot.status === "running";
const isStopped = bot.status === "stopped";
const isLoading =
startBot.isPending ||
stopBot.isPending ||
restartBot.isPending ||
deleteBot.isPending;
return (
<Card className="glass hover:border-primary/50 transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-lg">{bot.name}</CardTitle>
<p className="text-sm text-muted-foreground capitalize">
{bot.type.replace(/_/g, " ")}
</p>
</div>
<StatusBadge status={bot.status} />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isRunning && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Uptime:</span>
<span className="font-mono">
{bot.last_started_at
? formatRelativeTime(bot.last_started_at)
: "N/A"}
</span>
</div>
{bot.process_id && (
<div className="flex items-center gap-2 text-sm">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">PID:</span>
<span className="font-mono">{bot.process_id}</span>
</div>
)}
</div>
)}
{bot.status === "crashed" && (
<div className="text-sm text-destructive">
Last crash: {formatRelativeTime(bot.last_crash_at)}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RotateCw className="h-4 w-4" />
<span>Auto-restart: {bot.auto_restart ? "Enabled" : "Disabled"}</span>
</div>
</CardContent>
<CardFooter className="gap-2 flex-wrap">
{isStopped && (
<Button
size="sm"
onClick={handleStart}
disabled={isLoading}
className="flex-1"
>
<Play className="h-4 w-4 mr-1" />
Start
</Button>
)}
{isRunning && (
<>
<Button
size="sm"
variant="destructive"
onClick={handleStop}
disabled={isLoading}
className="flex-1"
>
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={isLoading}
className="flex-1"
>
<RotateCw className="h-4 w-4 mr-1" />
Restart
</Button>
</>
)}
{!isRunning && bot.status !== "stopped" && (
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={isLoading}
className="flex-1"
>
<RotateCw className="h-4 w-4 mr-1" />
Restart
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => onViewLogs?.(bot)}
className="flex-1"
>
<FileText className="h-4 w-4 mr-1" />
Logs
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDelete}
disabled={isLoading || isDeleting}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
/**
* Grid layout for bot cards.
*/
"use client";
import { Bot } from "@/types/bot";
import { BotCard } from "./BotCard";
interface BotGridProps {
bots: Bot[];
onViewLogs?: (bot: Bot) => void;
}
export function BotGrid({ bots, onViewLogs }: BotGridProps) {
if (bots.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">No bots found</p>
<p className="text-muted-foreground text-sm mt-2">
Create your first bot to get started
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{bots.map((bot) => (
<BotCard key={bot.id} bot={bot} onViewLogs={onViewLogs} />
))}
</div>
);
}

View File

@@ -0,0 +1,64 @@
/**
* Status badge component for displaying bot status.
*/
import { Badge } from "@/components/ui/badge";
import { BotStatus } from "@/types/bot";
import { cn } from "@/lib/utils";
interface StatusBadgeProps {
status: BotStatus;
className?: string;
}
export function StatusBadge({ status, className }: StatusBadgeProps) {
const getStatusConfig = (status: BotStatus) => {
switch (status) {
case BotStatus.RUNNING:
return {
variant: "default" as const,
label: "Running",
dotClass: "status-running",
};
case BotStatus.STOPPED:
return {
variant: "secondary" as const,
label: "Stopped",
dotClass: "status-stopped",
};
case BotStatus.CRASHED:
return {
variant: "destructive" as const,
label: "Crashed",
dotClass: "status-crashed",
};
case BotStatus.STARTING:
return {
variant: "outline" as const,
label: "Starting",
dotClass: "status-starting",
};
case BotStatus.STOPPING:
return {
variant: "outline" as const,
label: "Stopping",
dotClass: "status-stopping",
};
default:
return {
variant: "secondary" as const,
label: "Unknown",
dotClass: "status-stopped",
};
}
};
const config = getStatusConfig(status);
return (
<Badge variant={config.variant} className={cn("gap-1.5", className)}>
<span className={cn("status-dot", config.dotClass)} />
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,129 @@
/**
* System statistics display component.
*/
"use client";
import { useSystemStats } from "@/lib/hooks/useStats";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Cpu, HardDrive, MemoryStick, Network } from "lucide-react";
export function SystemStats() {
const { data: stats, isLoading } = useSystemStats();
if (isLoading || !stats) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="glass">
<CardHeader className="pb-2">
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="glass">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<Cpu className="h-4 w-4" />
CPU Usage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.cpu_percent.toFixed(1)}%</div>
<div className="mt-2 h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(stats.cpu_percent, 100)}%` }}
/>
</div>
</CardContent>
</Card>
<Card className="glass">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<MemoryStick className="h-4 w-4" />
RAM Usage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.ram_percent.toFixed(1)}%</div>
<div className="text-sm text-muted-foreground">
{stats.ram_used_mb.toFixed(0)} / {stats.ram_total_mb.toFixed(0)} MB
</div>
<div className="mt-2 h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(stats.ram_percent, 100)}%` }}
/>
</div>
</CardContent>
</Card>
<Card className="glass">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Disk Usage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.disk_percent.toFixed(1)}%</div>
<div className="text-sm text-muted-foreground">
{stats.disk_used_gb.toFixed(1)} / {stats.disk_total_gb.toFixed(1)} GB
</div>
<div className="mt-2 h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(stats.disk_percent, 100)}%` }}
/>
</div>
</CardContent>
</Card>
<Card className="glass">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<Network className="h-4 w-4" />
Bots Status
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total:</span>
<span className="font-mono">{stats.bots_total}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-500">Running:</span>
<span className="font-mono">{stats.bots_running}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Stopped:</span>
<span className="font-mono">{stats.bots_stopped}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-red-500">Crashed:</span>
<span className="font-mono">{stats.bots_crashed}</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

21
frontend/next.config.js Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
async rewrites() {
return [
{
source: '/api/:path*',
destination: process.env.NEXT_PUBLIC_API_URL + '/:path*',
},
];
},
webpack: (config) => {
config.resolve.alias.canvas = false;
return config;
},
};
module.exports = nextConfig;

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "bot-dashboard-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"next": "14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.32.0",
"axios": "^1.6.8",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0",
"lucide-react": "^0.379.0",
"recharts": "^2.12.6",
"sonner": "^1.4.41",
"class-variance-authority": "^0.7.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.4.19",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"prettier": "^3.2.5"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,89 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
50: "#faf5ff",
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
success: "#10b981",
warning: "#f59e0b",
error: "#ef4444",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

24
frontend/types/api.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Type definitions for API responses.
*/
export interface ApiError {
detail: string;
message?: string;
}
export interface PaginationParams {
page?: number;
page_size?: number;
}
export interface BotFilters extends PaginationParams {
status?: string;
search?: string;
}
export interface ApiResponse<T = any> {
data?: T;
error?: ApiError;
status: number;
}

61
frontend/types/bot.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* Type definitions for bot-related entities.
*/
export enum BotType {
TELEGRAM_USERBOT = "telegram_userbot",
TELEGRAM_BOT = "telegram_bot",
DISCORD_BOT = "discord_bot",
}
export enum BotStatus {
STOPPED = "stopped",
STARTING = "starting",
RUNNING = "running",
STOPPING = "stopping",
CRASHED = "crashed",
}
export interface Bot {
id: string;
name: string;
type: BotType;
config: Record<string, any>;
status: BotStatus;
auto_restart: boolean;
created_at: string;
updated_at: string;
last_started_at: string | null;
process_id: number | null;
restart_count: number;
last_crash_at: string | null;
}
export interface BotCreate {
name: string;
type: BotType;
config: Record<string, any>;
auto_restart: boolean;
}
export interface BotUpdate {
name?: string;
config?: Record<string, any>;
auto_restart?: boolean;
}
export interface BotListResponse {
total: number;
page: number;
page_size: number;
bots: Bot[];
}
export interface BotStatusResponse {
id: string;
name: string;
status: BotStatus;
process_id: number | null;
uptime_seconds: number | null;
last_started_at: string | null;
}

33
frontend/types/log.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Type definitions for log entries.
*/
export enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
WARNING = "WARNING",
ERROR = "ERROR",
CRITICAL = "CRITICAL",
}
export interface LogEntry {
id: number;
bot_id: string;
timestamp: string;
level: LogLevel;
message: string;
}
export interface LogListResponse {
total: number;
page: number;
page_size: number;
logs: LogEntry[];
}
export interface LogWebSocketMessage {
timestamp: string;
level: string;
message: string;
type?: "ping";
}

42
frontend/types/stats.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Type definitions for statistics.
*/
export interface SystemStats {
cpu_percent: number;
ram_used_mb: number;
ram_total_mb: number;
ram_percent: number;
disk_used_gb: number;
disk_total_gb: number;
disk_percent: number;
network_sent_mb: number;
network_recv_mb: number;
bots_total: number;
bots_running: number;
bots_stopped: number;
bots_crashed: number;
}
export interface BotStats {
bot_id: string;
bot_name: string;
cpu_percent: number | null;
ram_mb: number | null;
uptime_seconds: number | null;
status: string;
}
export interface AggregateStats {
total_bots: number;
total_cpu_percent: number;
total_ram_mb: number;
average_uptime_seconds: number | null;
bot_stats: BotStats[];
}
export interface StatsWebSocketMessage {
timestamp: string;
system: SystemStats;
bots: BotStats[];
}

102
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,102 @@
events {
worker_connections 1024;
}
http {
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:3000;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=general_limit:10m rate=100r/s;
server {
listen 80;
server_name _;
# Uncomment for production with SSL
# listen 443 ssl http2;
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 10M;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# API endpoints
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint
location /health {
proxy_pass http://backend/health;
proxy_set_header Host $host;
}
# WebSocket endpoints
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# API documentation
location /docs {
proxy_pass http://backend/docs;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://backend/redoc;
proxy_set_header Host $host;
}
# Frontend
location / {
limit_req zone=general_limit burst=50 nodelay;
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Uncomment to redirect HTTP to HTTPS in production
# if ($scheme != "https") {
# return 301 https://$host$request_uri;
# }
}
}