feat: Complete production-ready bot management dashboard system
Implement a comprehensive web-based dashboard for managing Telegram and Discord bots with real-time monitoring, process control, and beautiful UI. Backend (FastAPI): - Complete REST API with OpenAPI documentation - WebSocket support for real-time log streaming and statistics - SQLAlchemy models for bots, logs, and users - JWT-based authentication system - Process management with subprocess and psutil - Auto-restart functionality with configurable backoff - System and bot resource monitoring (CPU, RAM, network) - Comprehensive error handling and logging Frontend (Next.js 14 + TypeScript): - Modern React application with App Router - shadcn/ui components with Tailwind CSS - TanStack Query for data fetching and caching - Real-time WebSocket integration - Responsive design for mobile, tablet, and desktop - Beautiful dark theme with glassmorphism effects - Bot cards with status badges and controls - System statistics dashboard Example Bots: - Telegram userbot using Telethon - Telegram bot using python-telegram-bot - Discord bot using discord.py - Full command examples and error handling Infrastructure: - Docker and Docker Compose configurations - Multi-stage builds for optimal image sizes - Nginx reverse proxy with WebSocket support - Production and development compose files - Rate limiting and security headers Documentation: - Comprehensive README with setup instructions - API documentation examples - Configuration guides - Troubleshooting section - Makefile for common commands Features: - Start/stop/restart bots with one click - Real-time log streaming via WebSocket - Live system and bot statistics - Auto-restart on crashes - Bot configuration management - Process monitoring and resource tracking - Search and filter bots - Responsive UI with loading states - Toast notifications for all actions Security: - JWT token-based authentication - Password hashing with bcrypt - CORS configuration - Environment variable management - Input validation and sanitization - Rate limiting in nginx - Security headers configured
This commit is contained in:
78
.gitignore
vendored
Normal file
78
.gitignore
vendored
Normal 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
85
Makefile
Normal 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
463
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
## ✨ 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
10
backend/.env.example
Normal 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
45
backend/Dockerfile
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Bot Management Dashboard - Backend Application"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
58
backend/app/config.py
Normal file
58
backend/app/config.py
Normal 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
46
backend/app/database.py
Normal 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
193
backend/app/main.py
Normal 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(),
|
||||
)
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal 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
50
backend/app/models/bot.py
Normal 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
35
backend/app/models/log.py
Normal 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})>"
|
||||
24
backend/app/models/user.py
Normal file
24
backend/app/models/user.py
Normal 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})>"
|
||||
5
backend/app/routers/__init__.py
Normal file
5
backend/app/routers/__init__.py
Normal 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
152
backend/app/routers/auth.py
Normal 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
413
backend/app/routers/bots.py
Normal 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,
|
||||
}
|
||||
114
backend/app/routers/stats.py
Normal file
114
backend/app/routers/stats.py
Normal 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,
|
||||
}
|
||||
192
backend/app/routers/websocket.py
Normal file
192
backend/app/routers/websocket.py
Normal 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]
|
||||
29
backend/app/schemas/__init__.py
Normal file
29
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
81
backend/app/schemas/bot.py
Normal file
81
backend/app/schemas/bot.py
Normal 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]
|
||||
26
backend/app/schemas/log.py
Normal file
26
backend/app/schemas/log.py
Normal 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]
|
||||
40
backend/app/schemas/stats.py
Normal file
40
backend/app/schemas/stats.py
Normal 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]
|
||||
42
backend/app/schemas/user.py
Normal file
42
backend/app/schemas/user.py
Normal 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
|
||||
14
backend/app/services/__init__.py
Normal file
14
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
325
backend/app/services/bot_manager.py
Normal file
325
backend/app/services/bot_manager.py
Normal 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()
|
||||
158
backend/app/services/log_collector.py
Normal file
158
backend/app/services/log_collector.py
Normal 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
|
||||
260
backend/app/services/process_manager.py
Normal file
260
backend/app/services/process_manager.py
Normal 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)
|
||||
141
backend/app/services/stats_collector.py
Normal file
141
backend/app/services/stats_collector.py
Normal 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,
|
||||
}
|
||||
19
backend/app/utils/__init__.py
Normal file
19
backend/app/utils/__init__.py
Normal 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",
|
||||
]
|
||||
60
backend/app/utils/logger.py
Normal file
60
backend/app/utils/logger.py
Normal 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
|
||||
92
backend/app/utils/security.py
Normal file
92
backend/app/utils/security.py
Normal 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
|
||||
8
backend/requirements-dev.txt
Normal file
8
backend/requirements-dev.txt
Normal 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
13
backend/requirements.txt
Normal 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
|
||||
168
bots/examples/discord_bot.py
Normal file
168
bots/examples/discord_bot.py
Normal 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()
|
||||
3
bots/examples/requirements.txt
Normal file
3
bots/examples/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
telethon==1.34.0
|
||||
python-telegram-bot==20.8
|
||||
discord.py==2.3.2
|
||||
150
bots/examples/telegram_bot.py
Normal file
150
bots/examples/telegram_bot.py
Normal 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()
|
||||
117
bots/examples/telegram_userbot.py
Normal file
117
bots/examples/telegram_userbot.py
Normal 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
61
docker-compose.prod.yml
Normal 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
50
docker-compose.yml
Normal 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
|
||||
2
frontend/.env.local.example
Normal file
2
frontend/.env.local.example
Normal 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
3
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
49
frontend/Dockerfile
Normal file
49
frontend/Dockerfile
Normal 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
125
frontend/app/globals.css
Normal 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
33
frontend/app/layout.tsx
Normal 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
127
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/app/providers.tsx
Normal file
28
frontend/app/providers.tsx
Normal 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
16
frontend/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
192
frontend/components/dashboard/BotCard.tsx
Normal file
192
frontend/components/dashboard/BotCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/components/dashboard/BotGrid.tsx
Normal file
34
frontend/components/dashboard/BotGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/components/dashboard/StatusBadge.tsx
Normal file
64
frontend/components/dashboard/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/components/dashboard/SystemStats.tsx
Normal file
129
frontend/components/dashboard/SystemStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/components/ui/badge.tsx
Normal file
36
frontend/components/ui/badge.tsx
Normal 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 }
|
||||
57
frontend/components/ui/button.tsx
Normal file
57
frontend/components/ui/button.tsx
Normal 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 }
|
||||
76
frontend/components/ui/card.tsx
Normal file
76
frontend/components/ui/card.tsx
Normal 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 }
|
||||
25
frontend/components/ui/input.tsx
Normal file
25
frontend/components/ui/input.tsx
Normal 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 }
|
||||
24
frontend/components/ui/label.tsx
Normal file
24
frontend/components/ui/label.tsx
Normal 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 }
|
||||
27
frontend/components/ui/switch.tsx
Normal file
27
frontend/components/ui/switch.tsx
Normal 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
21
frontend/next.config.js
Normal 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
45
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
89
frontend/tailwind.config.ts
Normal file
89
frontend/tailwind.config.ts
Normal 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
27
frontend/tsconfig.json
Normal 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
24
frontend/types/api.ts
Normal 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
61
frontend/types/bot.ts
Normal 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
33
frontend/types/log.ts
Normal 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
42
frontend/types/stats.ts
Normal 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
102
nginx/nginx.conf
Normal 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;
|
||||
# }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user