commit ecc1676fe5c28f17a97c40c6d116574c569bedb7 Author: ImpulsiveFPS Date: Sun Feb 1 15:55:16 2026 +0100 Initial commit: IPMI Fan Control application diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..734dbcb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +frontend/dist/ +frontend/build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Data (persist via volume) +data/ +*.db +*.sqlite +*.sqlite3 + +# Environment +.env +.env.local +.env.*.local + +# Testing +coverage/ +htmlcov/ +.pytest_cache/ +.tox/ + +# Documentation +docs/ +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1afbb56 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# IPMI Fan Control Configuration + +# Security - CHANGE THIS IN PRODUCTION! +SECRET_KEY=your-secure-secret-key-minimum-32-characters-long + +# Fan Control Settings +PANIC_TIMEOUT_SECONDS=60 +PANIC_FAN_SPEED=100 + +# Database (default: SQLite) +# DATABASE_URL=sqlite:///app/data/ipmi_fan_control.db +# For PostgreSQL: +# DATABASE_URL=postgresql://user:password@db:5432/ipmi_fan_control + +# Debug mode (disable in production) +DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4da02e --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Build outputs +frontend/dist/ +frontend/build/ +*.egg-info/ +dist/ +build/ + +# Environment +.env +.env.local +.env.*.local + +# Data and databases +data/ +*.db +*.sqlite +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +coverage/ +htmlcov/ +.pytest_cache/ +.tox/ + +# Docker +.docker/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b83e87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# Multi-stage build for IPMI Fan Control + +# Backend stage +FROM python:3.11-slim as backend-builder + +WORKDIR /app/backend + +# Install system dependencies for ipmitool +RUN apt-get update && apt-get install -y \ + ipmitool \ + gcc \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend source +COPY backend/ . + +# Frontend stage +FROM node:20-alpine as frontend-builder + +WORKDIR /app/frontend + +# Install dependencies +COPY frontend/package*.json ./ +RUN npm ci + +# Copy frontend source and build +COPY frontend/ . +RUN npm run build + +# Final stage +FROM python:3.11-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ipmitool \ + && rm -rf /var/lib/apt/lists/* + +# Copy Python dependencies from backend builder +COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=backend-builder /usr/local/bin /usr/local/bin + +# Copy backend application +COPY --from=backend-builder /app/backend /app/backend + +# Copy frontend build +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist + +# Create data directory +RUN mkdir -p /app/data + +# Environment variables +ENV PYTHONPATH=/app/backend +ENV DATA_DIR=/app/data +ENV SECRET_KEY=change-me-in-production + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +# Start command +CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d305996 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# IPMI Fan Control + +A modern web-based application for controlling fan speeds on Dell T710 and compatible servers using IPMI. Features a clean web UI, automatic fan curves, panic mode for safety, and support for multiple servers. + +![Dashboard](docs/dashboard.png) + +## Features + +- 🖥️ **Multiple Server Support** - Manage multiple servers from a single interface +- 🌡️ **Temperature-Based Fan Curves** - Automatically adjust fan speeds based on CPU temperatures +- ⚡ **Panic Mode** - Automatically sets fans to 100% if sensor data is lost +- 🎛️ **Manual Fan Control** - Direct control over individual fans or all fans at once +- 📊 **Real-time Monitoring** - View temperatures, fan speeds, and power consumption +- 🔒 **Secure Authentication** - JWT-based authentication with encrypted passwords +- 🐳 **Docker Support** - Easy deployment with Docker Compose +- 🔄 **Persistent Storage** - Settings and credentials survive container restarts + +## Supported Servers + +- Dell PowerEdge T710 (tested) +- Dell PowerEdge R710/R720/R730 (should work) +- Dell PowerEdge R810/R820/R910/R920 (should work) +- HPE servers with iLO (partial support) + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose installed +- IPMI enabled on your server(s) with network access + +### Installation + +1. Clone the repository: +```bash +git clone https://git.lemonlink.eu/impulsivefps/ipmi-fan-control.git +cd ipmi-fan-control +``` + +2. Copy the example environment file and edit it: +```bash +cp .env.example .env +# Edit .env and set a secure SECRET_KEY +``` + +3. Create the data directory: +```bash +mkdir -p data +``` + +4. Start the application: +```bash +docker-compose up -d +``` + +5. Access the web interface at `http://your-server-ip:8000` + +6. Complete the setup wizard to create your admin account + +### First Time Setup + +1. When you first access the web UI, you'll be guided through a setup wizard +2. Create an administrator account +3. Add your first server by providing: + - Server name + - IP address/hostname + - IPMI username and password + - Vendor (Dell, HPE, etc.) + +## Usage + +### Manual Fan Control + +1. Go to a server's detail page +2. Click on the "Fan Control" tab +3. Enable "Manual Fan Control" +4. Use the slider to set the desired fan speed +5. Click "Apply" to send the command + +### Automatic Fan Curves + +1. Go to the "Fan Curves" page for your server +2. Create a new fan curve with temperature/speed points +3. Select the curve and click "Start Auto Control" +4. The system will automatically adjust fan speeds based on temperatures + +### Panic Mode + +Panic mode is enabled by default. If the application loses connection to a server and cannot retrieve sensor data for the configured timeout period (default: 60 seconds), it will automatically set all fans to 100% speed for safety. + +## IPMI Commands Used + +This application uses the following IPMI raw commands: + +### Dell Servers + +- **Enable Manual Control**: `raw 0x30 0x30 0x01 0x00` +- **Disable Manual Control**: `raw 0x30 0x30 0x01 0x01` +- **Set Fan Speed**: `raw 0x30 0x30 0x02 ` +- **Get 3rd Party PCIe Status**: `raw 0x30 0xce 0x01 0x16 0x05 0x00 0x00 0x00` +- **Enable 3rd Party PCIe Response**: `raw 0x30 0xce 0x00 0x16 0x05 0x00 0x00 0x00 0x05 0x00 0x00 0x00 0x00` +- **Disable 3rd Party PCIe Response**: `raw 0x30 0xce 0x00 0x16 0x05 0x00 0x00 0x00 0x05 0x00 0x01 0x00 0x00` + +### Standard IPMI + +- **Get Temperatures**: `sdr type temperature` +- **Get All Sensors**: `sdr elist full` +- **Get Power Supply Status**: `sdr type 'Power Supply'` +- **Dell Power Monitor**: `delloem powermonitor` + +## Fan Mapping + +For Dell servers, the fan mapping is: + +| IPMI ID | Physical Fan | +|---------|--------------| +| 0x00 | Fan 1 | +| 0x01 | Fan 2 | +| 0x02 | Fan 3 | +| 0x03 | Fan 4 | +| 0x04 | Fan 5 | +| 0x05 | Fan 6 | +| 0x06 | Fan 7 | +| 0xff | All Fans | + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SECRET_KEY` | (required) | Secret key for JWT tokens | +| `DATA_DIR` | /app/data | Directory for persistent data | +| `PANIC_TIMEOUT_SECONDS` | 60 | Seconds before panic mode activates | +| `PANIC_FAN_SPEED` | 100 | Fan speed during panic mode (%) | +| `DATABASE_URL` | sqlite:///app/data/... | Database connection string | +| `DEBUG` | false | Enable debug mode | + +### Docker Compose + +```yaml +version: '3.8' + +services: + ipmi-fan-control: + image: ipmi-fan-control:latest + container_name: ipmi-fan-control + restart: unless-stopped + ports: + - "8000:8000" + environment: + - SECRET_KEY=your-secure-secret-key + - PANIC_TIMEOUT_SECONDS=60 + volumes: + - ./data:/app/data +``` + +## Building from Source + +### Backend + +```bash +cd backend +pip install -r requirements.txt +python -m uvicorn main:app --reload +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +## Development + +The application consists of: + +- **Backend**: Python FastAPI with SQLAlchemy ORM +- **Frontend**: React with TypeScript, Material-UI, and Recharts +- **Database**: SQLite (default) or PostgreSQL +- **IPMI**: Direct integration with ipmitool + +## Security Considerations + +1. **Change the default SECRET_KEY** in production +2. Use HTTPS when accessing over the internet +3. Place behind a reverse proxy (nginx, traefik) for SSL termination +4. Use strong passwords for the admin account +5. Server passwords are encrypted at rest using Fernet encryption +6. Regularly update the Docker image for security patches + +## Troubleshooting + +### Cannot connect to server + +1. Verify IPMI is enabled in the server's BIOS/iDRAC settings +2. Check network connectivity: `ping ` +3. Test IPMI manually: `ipmitool -I lanplus -H -U -P mc info` +4. Check firewall rules allow IPMI traffic (port 623) + +### Fan control not working + +1. Ensure manual fan control is enabled first +2. Check that the server supports the IPMI raw commands +3. Verify you have admin/root IPMI privileges +4. Some servers require 3rd party PCIe card response to be disabled + +### Container won't start + +1. Check logs: `docker-compose logs -f` +2. Verify data directory has correct permissions +3. Ensure port 8000 is not already in use + +## License + +MIT License - See LICENSE file for details + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## Support + +For issues and feature requests, please use the GitHub issue tracker. + +## Acknowledgments + +- Dell for the IPMI command documentation +- The ipmitool project +- FastAPI and React communities diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..5c3b2a0 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# IPMI Fan Control Backend diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..29304a1 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,98 @@ +"""Authentication and security utilities.""" +from datetime import datetime, timedelta +from typing import Optional, Union +import secrets +import hashlib +import base64 +import logging + +from jose import JWTError, jwt +from passlib.context import CryptContext +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from config import settings + +logger = logging.getLogger(__name__) + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Encryption key for server passwords (generated from secret key) +def get_encryption_key() -> bytes: + """Generate encryption key from secret key.""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b"ipmi_fan_control_salt", # Fixed salt - in production use random salt stored in DB + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(settings.SECRET_KEY.encode())) + return key + + +def get_fernet() -> Fernet: + """Get Fernet cipher instance.""" + return Fernet(get_encryption_key()) + + +def encrypt_password(password: str) -> str: + """Encrypt a password for storage.""" + try: + f = get_fernet() + encrypted = f.encrypt(password.encode()) + return encrypted.decode() + except Exception as e: + logger.error(f"Failed to encrypt password: {e}") + raise + + +def decrypt_password(encrypted_password: str) -> str: + """Decrypt a stored password.""" + try: + f = get_fernet() + decrypted = f.decrypt(encrypted_password.encode()) + return decrypted.decode() + except Exception as e: + logger.error(f"Failed to decrypt password: {e}") + raise + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create JWT access token.""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """Decode and verify JWT access token.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError as e: + logger.warning(f"JWT decode error: {e}") + return None + + +def generate_setup_token() -> str: + """Generate a one-time setup token for initial configuration.""" + return secrets.token_urlsafe(32) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..6a79972 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,47 @@ +"""Application configuration.""" +import os +from pathlib import Path +from pydantic_settings import BaseSettings +from pydantic import Field + +BASE_DIR = Path(__file__).parent +DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data")) +DATA_DIR.mkdir(parents=True, exist_ok=True) + + +class Settings(BaseSettings): + """Application settings.""" + + # Security + SECRET_KEY: str = Field(default="change-me-in-production", description="Secret key for JWT") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # Database + DATABASE_URL: str = Field(default=f"sqlite:///{DATA_DIR}/ipmi_fan_control.db") + + # IPMI Settings + IPMITOOL_PATH: str = Field(default="ipmitool", description="Path to ipmitool binary") + PANIC_TIMEOUT_SECONDS: int = Field(default=60, description="Seconds without sensor data before panic mode") + PANIC_FAN_SPEED: int = Field(default=100, description="Fan speed during panic mode") + + # Fan Control Settings + DEFAULT_FAN_CURVE: list = Field(default=[ + {"temp": 30, "speed": 10}, + {"temp": 40, "speed": 20}, + {"temp": 50, "speed": 35}, + {"temp": 60, "speed": 50}, + {"temp": 70, "speed": 70}, + {"temp": 80, "speed": 100}, + ]) + + # App Settings + APP_NAME: str = "IPMI Fan Control" + DEBUG: bool = False + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fa26585 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,185 @@ +"""Database models and session management.""" +from datetime import datetime +from typing import Optional, List +import json + +from sqlalchemy import ( + create_engine, Column, Integer, String, Boolean, + DateTime, Float, ForeignKey, Text, event +) +from sqlalchemy.orm import declarative_base, sessionmaker, relationship, Session +from sqlalchemy.pool import StaticPool + +from config import settings, DATA_DIR + +# Create engine +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) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +# Database Models +class User(Base): + """Admin user model.""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + last_login = Column(DateTime, nullable=True) + + +class Server(Base): + """IPMI Server model.""" + __tablename__ = "servers" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + host = Column(String(100), nullable=False) # IP address + port = Column(Integer, default=623) + username = Column(String(100), nullable=False) + encrypted_password = Column(String(255), nullable=False) # Encrypted password + + # Server type (dell, hpe, etc.) + vendor = Column(String(50), default="dell") + + # Fan control settings + manual_control_enabled = Column(Boolean, default=False) + third_party_pcie_response = Column(Boolean, default=True) + fan_curve_data = Column(Text, nullable=True) # JSON string + auto_control_enabled = Column(Boolean, default=False) + + # Panic mode settings + panic_mode_enabled = Column(Boolean, default=True) + panic_timeout_seconds = Column(Integer, default=60) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + last_seen = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True) + + # Relationships + sensor_data = relationship("SensorData", back_populates="server", cascade="all, delete-orphan") + fan_data = relationship("FanData", back_populates="server", cascade="all, delete-orphan") + + +class FanCurve(Base): + """Fan curve configuration.""" + __tablename__ = "fan_curves" + + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) + name = Column(String(100), default="Default") + curve_data = Column(Text, nullable=False) # JSON array of {temp, speed} points + sensor_source = Column(String(50), default="cpu") # cpu, inlet, exhaust, etc. + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class SensorData(Base): + """Historical sensor data.""" + __tablename__ = "sensor_data" + + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) + sensor_name = Column(String(100), nullable=False) + sensor_type = Column(String(50), nullable=False) # temperature, voltage, fan, power + value = Column(Float, nullable=False) + unit = Column(String(20), nullable=True) + timestamp = Column(DateTime, default=datetime.utcnow) + + server = relationship("Server", back_populates="sensor_data") + + +class FanData(Base): + """Historical fan speed data.""" + __tablename__ = "fan_data" + + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) + fan_number = Column(Integer, nullable=False) + fan_id = Column(String(20), nullable=False) # IPMI fan ID (0x00, 0x01, etc.) + speed_rpm = Column(Integer, nullable=True) + speed_percent = Column(Integer, nullable=True) + is_manual = Column(Boolean, default=False) + timestamp = Column(DateTime, default=datetime.utcnow) + + server = relationship("Server", back_populates="fan_data") + + +class SystemLog(Base): + """System event logs.""" + __tablename__ = "system_logs" + + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("servers.id"), nullable=True) + event_type = Column(String(50), nullable=False) # panic, fan_change, error, warning, info + message = Column(Text, nullable=False) + details = Column(Text, nullable=True) + timestamp = Column(DateTime, default=datetime.utcnow) + + +class AppSettings(Base): + """Application settings storage.""" + __tablename__ = "app_settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), unique=True, nullable=False) + value = Column(Text, nullable=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +def get_db() -> Session: + """Get database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize database tables.""" + Base.metadata.create_all(bind=engine) + + # Create default admin if no users exist + db = SessionLocal() + try: + # Check if setup is complete + setup_complete = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first() + if not setup_complete: + AppSettings(key="setup_complete", value="false") + db.add(AppSettings(key="setup_complete", value="false")) + db.commit() + finally: + db.close() + + +def is_setup_complete(db: Session) -> bool: + """Check if initial setup is complete.""" + setting = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first() + if setting: + return setting.value == "true" + return False + + +def set_setup_complete(db: Session, complete: bool = True): + """Mark setup as complete.""" + setting = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first() + if setting: + setting.value = "true" if complete else "false" + else: + setting = AppSettings(key="setup_complete", value="true" if complete else "false") + db.add(setting) + db.commit() diff --git a/backend/fan_control.py b/backend/fan_control.py new file mode 100644 index 0000000..81b5606 --- /dev/null +++ b/backend/fan_control.py @@ -0,0 +1,381 @@ +"""Fan control logic including curves and automatic control.""" +import json +import logging +import asyncio +from typing import List, Dict, Optional, Any +from dataclasses import dataclass, asdict +from datetime import datetime, timedelta +from enum import Enum + +from sqlalchemy.orm import Session + +from database import ( + Server, FanCurve, SensorData, FanData, SystemLog, + get_db, SessionLocal +) +from ipmi_client import IPMIClient, TemperatureReading +from config import settings + +logger = logging.getLogger(__name__) + + +class ControlState(Enum): + """Fan control state.""" + AUTO = "auto" + MANUAL = "manual" + PANIC = "panic" + OFF = "off" + + +@dataclass +class FanCurvePoint: + """Single point on a fan curve.""" + temp: float + speed: int + + +class FanCurveManager: + """Manages fan curve calculations.""" + + @staticmethod + def parse_curve(curve_data: str) -> List[FanCurvePoint]: + """Parse fan curve from JSON string.""" + try: + data = json.loads(curve_data) + return [FanCurvePoint(p["temp"], p["speed"]) for p in data] + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"Failed to parse fan curve: {e}") + # Return default curve + return [ + FanCurvePoint(30, 10), + FanCurvePoint(40, 20), + FanCurvePoint(50, 35), + FanCurvePoint(60, 50), + FanCurvePoint(70, 70), + FanCurvePoint(80, 100), + ] + + @staticmethod + def serialize_curve(points: List[FanCurvePoint]) -> str: + """Serialize fan curve to JSON string.""" + return json.dumps([{"temp": p.temp, "speed": p.speed} for p in points]) + + @staticmethod + def calculate_speed(curve: List[FanCurvePoint], temperature: float) -> int: + """ + Calculate fan speed for a given temperature using linear interpolation. + """ + if not curve: + return 50 # Default to 50% if no curve + + # Sort by temperature + sorted_curve = sorted(curve, key=lambda p: p.temp) + + # Below minimum temp + if temperature <= sorted_curve[0].temp: + return sorted_curve[0].speed + + # Above maximum temp + if temperature >= sorted_curve[-1].temp: + return sorted_curve[-1].speed + + # Find surrounding points + for i in range(len(sorted_curve) - 1): + p1 = sorted_curve[i] + p2 = sorted_curve[i + 1] + + if p1.temp <= temperature <= p2.temp: + # Linear interpolation + if p2.temp == p1.temp: + return p1.speed + + ratio = (temperature - p1.temp) / (p2.temp - p1.temp) + speed = p1.speed + ratio * (p2.speed - p1.speed) + return int(round(speed)) + + return sorted_curve[-1].speed + + +class FanController: + """Main fan controller for managing server fans.""" + + def __init__(self): + self.curve_manager = FanCurveManager() + self.running = False + self._tasks: Dict[int, asyncio.Task] = {} # server_id -> task + self._last_sensor_data: Dict[int, datetime] = {} # server_id -> timestamp + + async def start(self): + """Start the fan controller service.""" + self.running = True + logger.info("Fan controller started") + + # Load all servers with auto-control enabled + db = SessionLocal() + try: + servers = db.query(Server).filter( + Server.auto_control_enabled == True, + Server.is_active == True + ).all() + + for server in servers: + await self.start_server_control(server.id) + finally: + db.close() + + async def stop(self): + """Stop all fan control tasks.""" + self.running = False + for task in self._tasks.values(): + task.cancel() + self._tasks.clear() + logger.info("Fan controller stopped") + + async def start_server_control(self, server_id: int): + """Start automatic control for a server.""" + if server_id in self._tasks: + self._tasks[server_id].cancel() + + task = asyncio.create_task(self._control_loop(server_id)) + self._tasks[server_id] = task + logger.info(f"Started fan control for server {server_id}") + + async def stop_server_control(self, server_id: int): + """Stop automatic control for a server.""" + if server_id in self._tasks: + self._tasks[server_id].cancel() + del self._tasks[server_id] + logger.info(f"Stopped fan control for server {server_id}") + + async def _control_loop(self, server_id: int): + """Main control loop for a server.""" + while self.running: + try: + await self._control_iteration(server_id) + await asyncio.sleep(5) # 5 second interval + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Control loop error for server {server_id}: {e}") + await asyncio.sleep(10) + + async def _control_iteration(self, server_id: int): + """Single control iteration for a server.""" + db = SessionLocal() + try: + server = db.query(Server).filter(Server.id == server_id).first() + if not server or not server.is_active: + return + + # Create IPMI client + from auth import decrypt_password + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + # Test connection + if not client.test_connection(): + logger.warning(f"Cannot connect to server {server.name}") + await self._handle_connection_loss(db, server) + return + + # Get sensor data + temps = client.get_temperatures() + fans = client.get_fan_speeds() + all_sensors = client.get_all_sensors() + + # Store sensor data + self._store_sensor_data(db, server_id, temps, fans, all_sensors) + + # Update last sensor data time + self._last_sensor_data[server_id] = datetime.utcnow() + server.last_seen = datetime.utcnow() + + # Check panic mode + if self._should_panic(db, server_id, server): + await self._enter_panic_mode(db, server, client) + return + + # Calculate and set fan speed if auto control is enabled + if server.auto_control_enabled: + await self._apply_fan_curve(db, server, client, temps) + + db.commit() + + finally: + db.close() + + def _store_sensor_data(self, db: Session, server_id: int, + temps: List[TemperatureReading], + fans: List[Any], + all_sensors: List[Any]): + """Store sensor data in database.""" + now = datetime.utcnow() + + # Store temperature readings + for temp in temps: + sensor = SensorData( + server_id=server_id, + sensor_name=temp.name, + sensor_type="temperature", + value=temp.value, + unit="°C", + timestamp=now + ) + db.add(sensor) + + # Store fan readings + for fan in fans: + fan_data = FanData( + server_id=server_id, + fan_number=fan.fan_number, + fan_id=fan.fan_id, + speed_rpm=fan.speed_rpm, + speed_percent=fan.speed_percent, + is_manual=False, + timestamp=now + ) + db.add(fan_data) + + def _should_panic(self, db: Session, server_id: int, server: Server) -> bool: + """Check if we should enter panic mode.""" + if not server.panic_mode_enabled: + return False + + last_seen = self._last_sensor_data.get(server_id) + if not last_seen: + return False + + timeout = server.panic_timeout_seconds or settings.PANIC_TIMEOUT_SECONDS + elapsed = (datetime.utcnow() - last_seen).total_seconds() + + if elapsed > timeout: + logger.warning(f"Panic mode triggered for server {server.name}: " + f"No sensor data for {elapsed:.0f}s") + return True + + return False + + async def _enter_panic_mode(self, db: Session, server: Server, client: IPMIClient): + """Enter panic mode - set fans to 100%.""" + logger.critical(f"Entering PANIC MODE for server {server.name}") + + # Log the event + log = SystemLog( + server_id=server.id, + event_type="panic", + message=f"Panic mode activated - No sensor data received", + details=f"Setting all fans to {settings.PANIC_FAN_SPEED}%" + ) + db.add(log) + + # Enable manual control if not already + if not server.manual_control_enabled: + client.enable_manual_fan_control() + server.manual_control_enabled = True + + # Set fans to max + client.set_all_fans_speed(settings.PANIC_FAN_SPEED) + + db.commit() + + async def _apply_fan_curve(self, db: Session, server: Server, + client: IPMIClient, temps: List[TemperatureReading]): + """Apply fan curve based on temperatures.""" + if not temps: + return + + # Get active fan curve + curve_data = server.fan_curve_data + if not curve_data: + # Use default curve + curve = [ + FanCurvePoint(30, 10), + FanCurvePoint(40, 20), + FanCurvePoint(50, 35), + FanCurvePoint(60, 50), + FanCurvePoint(70, 70), + FanCurvePoint(80, 100), + ] + else: + curve = self.curve_manager.parse_curve(curve_data) + + # Find the highest CPU temperature + cpu_temps = [t for t in temps if t.location.startswith("cpu")] + if cpu_temps: + max_temp = max(t.value for t in cpu_temps) + else: + # Fall back to highest overall temp + max_temp = max(t.value for t in temps) + + # Calculate target speed + target_speed = self.curve_manager.calculate_speed(curve, max_temp) + + # Enable manual control if not already + if not server.manual_control_enabled: + if client.enable_manual_fan_control(): + server.manual_control_enabled = True + logger.info(f"Enabled manual fan control for {server.name}") + + # Set fan speed + current_fans = client.get_fan_speeds() + avg_current_speed = 0 + if current_fans: + # Estimate current speed from RPM if possible + avg_current_speed = 50 # Default assumption + + # Only update if speed changed significantly (avoid constant small changes) + if abs(target_speed - avg_current_speed) >= 5: + if client.set_all_fans_speed(target_speed): + logger.info(f"Set {server.name} fans to {target_speed}% (temp: {max_temp}°C)") + + async def _handle_connection_loss(self, db: Session, server: Server): + """Handle connection loss to a server.""" + logger.warning(f"Connection lost to server {server.name}") + + # Check if we should panic + server_id = server.id + last_seen = self._last_sensor_data.get(server_id) + + if last_seen: + timeout = server.panic_timeout_seconds or settings.PANIC_TIMEOUT_SECONDS + elapsed = (datetime.utcnow() - last_seen).total_seconds() + + if elapsed > timeout and server.panic_mode_enabled: + log = SystemLog( + server_id=server.id, + event_type="error", + message=f"Connection lost to server", + details=f"Last seen {elapsed:.0f} seconds ago" + ) + db.add(log) + db.commit() + + def get_controller_status(self, server_id: int) -> Dict[str, Any]: + """Get current controller status for a server.""" + is_running = server_id in self._tasks + last_seen = self._last_sensor_data.get(server_id) + + return { + "is_running": is_running, + "last_sensor_data": last_seen.isoformat() if last_seen else None, + "state": ControlState.AUTO.value if is_running else ControlState.OFF.value + } + + +# Global controller instance +fan_controller = FanController() + + +async def initialize_fan_controller(): + """Initialize and start the fan controller.""" + await fan_controller.start() + + +async def shutdown_fan_controller(): + """Shutdown the fan controller.""" + await fan_controller.stop() diff --git a/backend/ipmi_client.py b/backend/ipmi_client.py new file mode 100644 index 0000000..7f226f9 --- /dev/null +++ b/backend/ipmi_client.py @@ -0,0 +1,376 @@ +"""IPMI client for communicating with servers.""" +import subprocess +import re +import json +import logging +from typing import List, Dict, Optional, Tuple, Any +from dataclasses import dataclass +from datetime import datetime + +from config import settings + +logger = logging.getLogger(__name__) + + +@dataclass +class SensorReading: + """Sensor reading data.""" + name: str + sensor_type: str + value: float + unit: str + status: str + + +@dataclass +class FanReading: + """Fan speed reading.""" + fan_id: str + fan_number: int + speed_rpm: Optional[int] + speed_percent: Optional[int] + + +@dataclass +class TemperatureReading: + """Temperature reading.""" + name: str + location: str + value: float + status: str + + +class IPMIClient: + """IPMI client for server communication.""" + + # Fan number mapping: IPMI ID -> Physical fan number + FAN_MAPPING = { + "0x00": 1, "0x01": 2, "0x02": 3, "0x03": 4, + "0x04": 5, "0x05": 6, "0x06": 7, "0x07": 8, + } + + # Hex to percent conversion + HEX_TO_PERCENT = {f"0x{i:02x}": i for i in range(101)} + PERCENT_TO_HEX = {i: f"0x{i:02x}" for i in range(101)} + + def __init__(self, host: str, username: str, password: str, port: int = 623, vendor: str = "dell"): + self.host = host + self.username = username + self.password = password + self.port = port + self.vendor = vendor.lower() + self.base_cmd = [ + settings.IPMITOOL_PATH, + "-I", "lanplus", + "-H", host, + "-U", username, + "-P", password, + "-p", str(port), + ] + + def _run_command(self, args: List[str], timeout: int = 30) -> Tuple[bool, str, str]: + """Run IPMI command and return success, stdout, stderr.""" + cmd = self.base_cmd + args + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + ) + success = result.returncode == 0 + if not success: + logger.error(f"IPMI command failed: {result.stderr}") + return success, result.stdout, result.stderr + except subprocess.TimeoutExpired: + logger.error(f"IPMI command timed out after {timeout}s") + return False, "", "Command timed out" + except Exception as e: + logger.error(f"IPMI command error: {e}") + return False, "", str(e) + + def test_connection(self) -> bool: + """Test IPMI connection.""" + success, stdout, stderr = self._run_command(["mc", "info"], timeout=10) + return success + + def get_mc_info(self) -> Dict[str, Any]: + """Get Management Controller info.""" + success, stdout, stderr = self._run_command(["mc", "info"]) + if not success: + return {"error": stderr} + + info = {} + for line in stdout.splitlines(): + if ":" in line: + key, value = line.split(":", 1) + info[key.strip()] = value.strip() + return info + + def enable_manual_fan_control(self) -> bool: + """Enable manual fan control.""" + # Dell command: raw 0x30 0x30 0x01 0x00 + success, _, _ = self._run_command(["raw", "0x30", "0x30", "0x01", "0x00"]) + return success + + def disable_manual_fan_control(self) -> bool: + """Disable manual fan control (return to automatic).""" + # Dell command: raw 0x30 0x30 0x01 0x01 + success, _, _ = self._run_command(["raw", "0x30", "0x30", "0x01", "0x01"]) + return success + + def set_fan_speed(self, fan_id: str, speed_percent: int) -> bool: + """ + Set fan speed. + fan_id: '0xff' for all fans, or '0x00', '0x01', etc. for specific fan + speed_percent: 0-100 + """ + if speed_percent < 0: + speed_percent = 0 + if speed_percent > 100: + speed_percent = 100 + + hex_speed = self.PERCENT_TO_HEX.get(speed_percent, "0x32") + success, _, _ = self._run_command([ + "raw", "0x30", "0x30", "0x02", fan_id, hex_speed + ]) + return success + + def set_all_fans_speed(self, speed_percent: int) -> bool: + """Set all fans to the same speed.""" + return self.set_fan_speed("0xff", speed_percent) + + def get_third_party_pcie_response(self) -> Optional[bool]: + """Get 3rd party PCIe card response state.""" + success, stdout, _ = self._run_command([ + "raw", "0x30", "0xce", "0x01", "0x16", "0x05", "0x00", "0x00", "0x00" + ]) + if not success: + return None + + # Parse response: 00 00 00 = Enabled, 01 00 00 = Disabled + parts = stdout.strip().split() + if len(parts) >= 3: + return parts[0] == "00" # True if enabled + return None + + def enable_third_party_pcie_response(self) -> bool: + """Enable 3rd party PCIe card response.""" + success, _, _ = self._run_command([ + "raw", "0x30", "0xce", "0x00", "0x16", "0x05", "0x00", "0x00", "0x00", + "0x05", "0x00", "0x00", "0x00", "0x00" + ]) + return success + + def disable_third_party_pcie_response(self) -> bool: + """Disable 3rd party PCIe card response.""" + success, _, _ = self._run_command([ + "raw", "0x30", "0xce", "0x00", "0x16", "0x05", "0x00", "0x00", "0x00", + "0x05", "0x00", "0x01", "0x00", "0x00" + ]) + return success + + def get_temperatures(self) -> List[TemperatureReading]: + """Get temperature sensor readings.""" + success, stdout, _ = self._run_command(["sdr", "type", "temperature"]) + if not success: + return [] + + temps = [] + for line in stdout.splitlines(): + # Parse: Sensor Name | 01h | ok | 3.1 | 45 degrees C + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 5: + name = parts[0] + status = parts[2] if len(parts) > 2 else "unknown" + reading = parts[4] if len(parts) > 4 else "" + + # Extract temperature value + match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE) + if match: + value = float(match.group(1)) + # Determine location + location = self._determine_temp_location(name) + temps.append(TemperatureReading( + name=name, + location=location, + value=value, + status=status + )) + return temps + + def _determine_temp_location(self, name: str) -> str: + """Determine temperature sensor location from name.""" + name_lower = name.lower() + if "cpu" in name_lower or "proc" in name_lower: + if "1" in name or "one" in name_lower: + return "cpu1" + elif "2" in name or "two" in name_lower: + return "cpu2" + return "cpu" + elif "inlet" in name_lower or "ambient" in name_lower: + return "inlet" + elif "exhaust" in name_lower: + return "exhaust" + elif "chipset" in name_lower or "pch" in name_lower: + return "chipset" + elif "memory" in name_lower or "dimm" in name_lower: + return "memory" + elif "psu" in name_lower or "power supply" in name_lower: + return "psu" + return "other" + + def get_fan_speeds(self) -> List[FanReading]: + """Get fan speed readings.""" + success, stdout, _ = self._run_command(["sdr", "elist", "full"]) + if not success: + return [] + + fans = [] + for line in stdout.splitlines(): + # Look for fan entries: Fan1 RPM | 30h | ok | 29.1 | 4200 RPM + if "fan" in line.lower() and "rpm" in line.lower(): + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 5: + name = parts[0] + reading = parts[4] + + # Extract fan number + match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE) + fan_number = int(match.group(1)) if match else 0 + + # Map to IPMI fan ID + fan_id = None + for fid, num in self.FAN_MAPPING.items(): + if num == fan_number: + fan_id = fid + break + if not fan_id: + fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00" + + # Extract RPM + rpm_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE) + rpm = int(rpm_match.group(1)) if rpm_match else None + + fans.append(FanReading( + fan_id=fan_id, + fan_number=fan_number, + speed_rpm=rpm, + speed_percent=None # Calculate from RPM if max known + )) + + return fans + + def get_all_sensors(self) -> List[SensorReading]: + """Get all sensor readings.""" + success, stdout, _ = self._run_command(["sdr", "elist", "full"]) + if not success: + return [] + + sensors = [] + for line in stdout.splitlines(): + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 5: + name = parts[0] + sensor_type = self._determine_sensor_type(name) + reading = parts[4] + status = parts[2] if len(parts) > 2 else "unknown" + + # Extract value and unit + value, unit = self._parse_sensor_value(reading) + + if value is not None: + sensors.append(SensorReading( + name=name, + sensor_type=sensor_type, + value=value, + unit=unit, + status=status + )) + + return sensors + + def _determine_sensor_type(self, name: str) -> str: + """Determine sensor type from name.""" + name_lower = name.lower() + if "temp" in name_lower or "degrees" in name_lower: + return "temperature" + elif "fan" in name_lower or "rpm" in name_lower: + return "fan" + elif "volt" in name_lower or "v" in name_lower: + return "voltage" + elif "power" in name_lower or "watt" in name_lower or "psu" in name_lower: + return "power" + elif "current" in name_lower or "amp" in name_lower: + return "current" + return "other" + + def _parse_sensor_value(self, reading: str) -> Tuple[Optional[float], str]: + """Parse sensor value and unit from reading string.""" + # Temperature: "45 degrees C" + match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE) + if match: + return float(match.group(1)), "°C" + + # RPM: "4200 RPM" + match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE) + if match: + return float(match.group(1)), "RPM" + + # Voltage: "12.05 Volts" or "3.3 V" + match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Volts?|V)\b', reading, re.IGNORECASE) + if match: + return float(match.group(1)), "V" + + # Power: "250 Watts" or "250 W" + match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Watts?|W)\b', reading, re.IGNORECASE) + if match: + return float(match.group(1)), "W" + + # Current: "5.5 Amps" or "5.5 A" + match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Amps?|A)\b', reading, re.IGNORECASE) + if match: + return float(match.group(1)), "A" + + # Generic number + match = re.search(r'(\d+(?:\.\d+)?)', reading) + if match: + return float(match.group(1)), "" + + return None, "" + + def get_power_consumption(self) -> Optional[Dict[str, Any]]: + """Get power consumption data (Dell OEM command).""" + success, stdout, _ = self._run_command(["delloem", "powermonitor"]) + if not success: + return None + + power_data = {} + for line in stdout.splitlines(): + if ":" in line: + key, value = line.split(":", 1) + power_data[key.strip()] = value.strip() + return power_data + + def get_power_supply_status(self) -> List[SensorReading]: + """Get power supply sensor data.""" + success, stdout, _ = self._run_command(["sdr", "type", "Power Supply"]) + if not success: + return [] + + psus = [] + for line in stdout.splitlines(): + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 3: + name = parts[0] + status = parts[2] if len(parts) > 2 else "unknown" + + psus.append(SensorReading( + name=name, + sensor_type="power_supply", + value=1.0 if status.lower() == "ok" else 0.0, + unit="status", + status=status + )) + + return psus diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..3d3d3e3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,831 @@ +"""Main FastAPI application.""" +import logging +import os +from contextlib import asynccontextmanager +from typing import List, Optional + +from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from config import settings +from database import init_db, get_db, is_setup_complete, set_setup_complete, User, Server, FanCurve, SystemLog, SensorData, FanData +from auth import ( + verify_password, get_password_hash, create_access_token, + decode_access_token, encrypt_password, decrypt_password +) +from ipmi_client import IPMIClient +from fan_control import fan_controller, FanCurveManager, initialize_fan_controller, shutdown_fan_controller +from schemas import ( + UserCreate, UserLogin, UserResponse, Token, TokenData, + ServerCreate, ServerUpdate, ServerResponse, ServerDetailResponse, ServerStatusResponse, + FanCurveCreate, FanCurveResponse, FanCurvePoint, + SensorReading, TemperatureReading, FanReading, ServerSensorsResponse, + FanControlCommand, AutoControlSettings, FanCurveApply, + SystemLogResponse, SetupStatus, SetupComplete, DashboardStats, ServerDashboardData +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Security +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Get current authenticated user.""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_access_token(credentials.credentials) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + username: str = payload.get("sub") + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is inactive" + ) + + return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> Optional[User]: + """Get current user if authenticated, None otherwise.""" + try: + return await get_current_user(credentials, db) + except HTTPException: + return None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # Startup + logger.info("Starting up IPMI Fan Control...") + init_db() + await initialize_fan_controller() + yield + # Shutdown + logger.info("Shutting down IPMI Fan Control...") + await shutdown_fan_controller() + + +app = FastAPI( + title=settings.APP_NAME, + description="IPMI Fan Control for Dell T710 and compatible servers", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure for production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Setup wizard endpoints +@app.get("/api/setup/status", response_model=SetupStatus) +async def setup_status(db: Session = Depends(get_db)): + """Check if initial setup is complete.""" + return {"setup_complete": is_setup_complete(db)} + + +@app.post("/api/setup/complete", response_model=UserResponse) +async def complete_setup(setup_data: SetupComplete, db: Session = Depends(get_db)): + """Complete initial setup by creating admin user.""" + if is_setup_complete(db): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Setup already completed" + ) + + # Create admin user + hashed_password = get_password_hash(setup_data.password) + user = User( + username=setup_data.username, + hashed_password=hashed_password, + is_active=True + ) + db.add(user) + + # Mark setup as complete + set_setup_complete(db, True) + + db.commit() + db.refresh(user) + + logger.info(f"Setup completed - admin user '{setup_data.username}' created") + return user + + +# Authentication endpoints +@app.post("/api/auth/login", response_model=Token) +async def login(credentials: UserLogin, db: Session = Depends(get_db)): + """Login and get access token.""" + 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="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is inactive" + ) + + # Update last login + from datetime import datetime + user.last_login = datetime.utcnow() + db.commit() + + access_token = create_access_token(data={"sub": user.username}) + return {"access_token": access_token, "token_type": "bearer"} + + +@app.get("/api/auth/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + """Get current user info.""" + return current_user + + +# Server management endpoints +@app.get("/api/servers", response_model=List[ServerResponse]) +async def list_servers( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List all servers.""" + return db.query(Server).all() + + +@app.post("/api/servers", response_model=ServerResponse, status_code=status.HTTP_201_CREATED) +async def create_server( + server_data: ServerCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Add a new server.""" + # Encrypt password + encrypted_password = encrypt_password(server_data.password) + + server = Server( + name=server_data.name, + host=server_data.host, + port=server_data.port, + username=server_data.username, + encrypted_password=encrypted_password, + vendor=server_data.vendor + ) + db.add(server) + db.commit() + db.refresh(server) + + logger.info(f"Server '{server.name}' added by {current_user.username}") + return server + + +@app.get("/api/servers/{server_id}", response_model=ServerDetailResponse) +async def get_server( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get server details.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + return server + + +@app.put("/api/servers/{server_id}", response_model=ServerResponse) +async def update_server( + server_id: int, + server_data: ServerUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update server configuration.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + update_data = server_data.dict(exclude_unset=True) + + # Handle password update separately + if "password" in update_data: + server.encrypted_password = encrypt_password(update_data.pop("password")) + + # Update other fields + for field, value in update_data.items(): + if hasattr(server, field): + setattr(server, field, value) + + db.commit() + db.refresh(server) + + logger.info(f"Server '{server.name}' updated by {current_user.username}") + return server + + +@app.delete("/api/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete a server.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + # Stop fan control if running + await fan_controller.stop_server_control(server_id) + + db.delete(server) + db.commit() + + logger.info(f"Server '{server.name}' deleted by {current_user.username}") + return None + + +@app.get("/api/servers/{server_id}/status", response_model=ServerStatusResponse) +async def get_server_status( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get server connection and controller status.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + # Test connection + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + is_connected = client.test_connection() + except Exception as e: + logger.warning(f"Failed to connect to server {server.name}: {e}") + is_connected = False + + return { + "server": server, + "is_connected": is_connected, + "controller_status": fan_controller.get_controller_status(server_id) + } + + +# Sensor data endpoints +@app.get("/api/servers/{server_id}/sensors", response_model=ServerSensorsResponse) +async def get_server_sensors( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get current sensor readings from server.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + temps = client.get_temperatures() + fans = client.get_fan_speeds() + all_sensors = client.get_all_sensors() + + from datetime import datetime + return { + "server_id": server_id, + "temperatures": [t.__dict__ for t in temps], + "fans": [f.__dict__ for f in fans], + "all_sensors": [s.__dict__ for s in all_sensors], + "timestamp": datetime.utcnow() + } + except Exception as e: + logger.error(f"Failed to get sensors from {server.name}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get sensor data: {str(e)}") + + +@app.get("/api/servers/{server_id}/power", response_model=dict) +async def get_server_power( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get power consumption data.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + power_data = client.get_power_consumption() + if power_data is None: + raise HTTPException(status_code=500, detail="Power data not available") + + return power_data + except Exception as e: + logger.error(f"Failed to get power data from {server.name}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get power data: {str(e)}") + + +# Fan control endpoints +@app.post("/api/servers/{server_id}/fans/manual/enable") +async def enable_manual_fan_control( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Enable manual fan control.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + if client.enable_manual_fan_control(): + server.manual_control_enabled = True + server.auto_control_enabled = False # Disable auto when manual enabled + db.commit() + + # Log event + log = SystemLog( + server_id=server_id, + event_type="fan_change", + message="Manual fan control enabled", + details=f"Enabled by {current_user.username}" + ) + db.add(log) + db.commit() + + return {"success": True, "message": "Manual fan control enabled"} + else: + raise HTTPException(status_code=500, detail="Failed to enable manual fan control") + except Exception as e: + logger.error(f"Failed to enable manual control on {server.name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/servers/{server_id}/fans/manual/disable") +async def disable_manual_fan_control( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Disable manual fan control (return to automatic).""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + if client.disable_manual_fan_control(): + server.manual_control_enabled = False + server.auto_control_enabled = False + db.commit() + + # Stop auto control task if running + await fan_controller.stop_server_control(server_id) + + log = SystemLog( + server_id=server_id, + event_type="fan_change", + message="Manual fan control disabled (automatic mode)", + details=f"Disabled by {current_user.username}" + ) + db.add(log) + db.commit() + + return {"success": True, "message": "Fan control returned to automatic"} + else: + raise HTTPException(status_code=500, detail="Failed to disable manual fan control") + except Exception as e: + logger.error(f"Failed to disable manual control on {server.name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/servers/{server_id}/fans/set") +async def set_fan_speed( + server_id: int, + command: FanControlCommand, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Set fan speed manually.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.manual_control_enabled: + raise HTTPException( + status_code=400, + detail="Manual fan control is not enabled. Enable it first." + ) + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + if client.set_fan_speed(command.fan_id, command.speed_percent): + fan_desc = f"Fan {command.fan_id}" if command.fan_id != "0xff" else "All fans" + log = SystemLog( + server_id=server_id, + event_type="fan_change", + message=f"{fan_desc} speed set to {command.speed_percent}%", + details=f"Set by {current_user.username}" + ) + db.add(log) + db.commit() + + return {"success": True, "message": f"Fan speed set to {command.speed_percent}%"} + else: + raise HTTPException(status_code=500, detail="Failed to set fan speed") + except Exception as e: + logger.error(f"Failed to set fan speed on {server.name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Fan curve endpoints +@app.get("/api/servers/{server_id}/fan-curves", response_model=List[FanCurveResponse]) +async def list_fan_curves( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List fan curves for a server.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + return db.query(FanCurve).filter(FanCurve.server_id == server_id).all() + + +@app.post("/api/servers/{server_id}/fan-curves", response_model=FanCurveResponse) +async def create_fan_curve( + server_id: int, + curve_data: FanCurveCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create a new fan curve.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + curve = FanCurve( + server_id=server_id, + name=curve_data.name, + curve_data=FanCurveManager.serialize_curve([FanCurvePoint(p.temp, p.speed) for p in curve_data.curve_data]), + sensor_source=curve_data.sensor_source, + is_active=curve_data.is_active + ) + db.add(curve) + db.commit() + db.refresh(curve) + + return curve + + +@app.put("/api/servers/{server_id}/fan-curves/{curve_id}", response_model=FanCurveResponse) +async def update_fan_curve( + server_id: int, + curve_id: int, + curve_data: FanCurveCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update a fan curve.""" + curve = db.query(FanCurve).filter( + FanCurve.id == curve_id, + FanCurve.server_id == server_id + ).first() + + if not curve: + raise HTTPException(status_code=404, detail="Fan curve not found") + + curve.name = curve_data.name + curve.curve_data = FanCurveManager.serialize_curve([FanCurvePoint(p.temp, p.speed) for p in curve_data.curve_data]) + curve.sensor_source = curve_data.sensor_source + curve.is_active = curve_data.is_active + + db.commit() + db.refresh(curve) + + return curve + + +@app.delete("/api/servers/{server_id}/fan-curves/{curve_id}") +async def delete_fan_curve( + server_id: int, + curve_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete a fan curve.""" + curve = db.query(FanCurve).filter( + FanCurve.id == curve_id, + FanCurve.server_id == server_id + ).first() + + if not curve: + raise HTTPException(status_code=404, detail="Fan curve not found") + + db.delete(curve) + db.commit() + + return {"success": True} + + +# Auto control endpoints +@app.post("/api/servers/{server_id}/auto-control/enable") +async def enable_auto_control( + server_id: int, + settings_data: AutoControlSettings, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Enable automatic fan control with fan curve.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + # Set fan curve if specified + if settings_data.curve_id: + curve = db.query(FanCurve).filter( + FanCurve.id == settings_data.curve_id, + FanCurve.server_id == server_id + ).first() + if curve: + server.fan_curve_data = curve.curve_data + + server.auto_control_enabled = True + server.manual_control_enabled = True # Auto control requires manual mode + db.commit() + + # Start the controller + await fan_controller.start_server_control(server_id) + + log = SystemLog( + server_id=server_id, + event_type="fan_change", + message="Automatic fan control enabled", + details=f"Enabled by {current_user.username}" + ) + db.add(log) + db.commit() + + return {"success": True, "message": "Automatic fan control enabled"} + + +@app.post("/api/servers/{server_id}/auto-control/disable") +async def disable_auto_control( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Disable automatic fan control.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + server.auto_control_enabled = False + db.commit() + + # Stop the controller + await fan_controller.stop_server_control(server_id) + + log = SystemLog( + server_id=server_id, + event_type="fan_change", + message="Automatic fan control disabled", + details=f"Disabled by {current_user.username}" + ) + db.add(log) + db.commit() + + return {"success": True, "message": "Automatic fan control disabled"} + + +# Dashboard endpoints +@app.get("/api/dashboard/stats", response_model=DashboardStats) +async def get_dashboard_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get dashboard statistics.""" + servers = db.query(Server).all() + + total_servers = len(servers) + active_servers = sum(1 for s in servers if s.is_active) + manual_servers = sum(1 for s in servers if s.manual_control_enabled) + auto_servers = sum(1 for s in servers if s.auto_control_enabled) + + # Count servers in panic mode (this is a simplified check) + panic_servers = 0 + for server in servers: + status = fan_controller.get_controller_status(server.id) + if status.get("state") == "panic": + panic_servers += 1 + + # Get recent logs + recent_logs = db.query(SystemLog).order_by(SystemLog.timestamp.desc()).limit(10).all() + + return { + "total_servers": total_servers, + "active_servers": active_servers, + "manual_control_servers": manual_servers, + "auto_control_servers": auto_servers, + "panic_mode_servers": panic_servers, + "recent_logs": recent_logs + } + + +@app.get("/api/dashboard/servers/{server_id}", response_model=ServerDashboardData) +async def get_server_dashboard( + server_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get detailed dashboard data for a specific server.""" + server = db.query(Server).filter(Server.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + # Get current sensor data + temps = [] + fans = [] + power_data = None + + try: + client = IPMIClient( + host=server.host, + username=server.username, + password=decrypt_password(server.encrypted_password), + port=server.port, + vendor=server.vendor + ) + + if client.test_connection(): + temps_readings = client.get_temperatures() + temps = [t.__dict__ for t in temps_readings] + fans_readings = client.get_fan_speeds() + fans = [f.__dict__ for f in fans_readings] + power_data = client.get_power_consumption() + except Exception as e: + logger.warning(f"Could not fetch live data for {server.name}: {e}") + + # Get recent historical data + recent_sensor_data = db.query(SensorData).filter( + SensorData.server_id == server_id + ).order_by(SensorData.timestamp.desc()).limit(50).all() + + recent_fan_data = db.query(FanData).filter( + FanData.server_id == server_id + ).order_by(FanData.timestamp.desc()).limit(50).all() + + return { + "server": server, + "current_temperatures": temps, + "current_fans": fans, + "recent_sensor_data": recent_sensor_data, + "recent_fan_data": recent_fan_data, + "power_consumption": power_data + } + + +# System logs endpoint +@app.get("/api/logs", response_model=List[SystemLogResponse]) +async def get_system_logs( + server_id: Optional[int] = None, + event_type: Optional[str] = None, + limit: int = 100, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get system logs with optional filtering.""" + query = db.query(SystemLog) + + if server_id: + query = query.filter(SystemLog.server_id == server_id) + + if event_type: + query = query.filter(SystemLog.event_type == event_type) + + logs = query.order_by(SystemLog.timestamp.desc()).limit(limit).all() + return logs + + +# Health check endpoint (no auth required) +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +# Static files - serve frontend +frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist") +if os.path.exists(frontend_path): + app.mount("/assets", StaticFiles(directory=os.path.join(frontend_path, "assets")), name="assets") + + @app.get("/", include_in_schema=False) + async def serve_index(): + return FileResponse(os.path.join(frontend_path, "index.html")) + + @app.get("/{path:path}", include_in_schema=False) + async def serve_spa(path: str): + # For SPA routing, return index.html for all non-API routes + if not path.startswith("api/") and not path.startswith("assets/"): + file_path = os.path.join(frontend_path, path) + if os.path.exists(file_path) and os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(frontend_path, "index.html")) + raise HTTPException(status_code=404) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6a3f5e7 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +aiofiles==23.2.1 +httpx==0.26.0 +apscheduler==3.10.4 +psutil==5.9.8 +asyncpg==0.29.0 +aiosqlite==0.19.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..ade372b --- /dev/null +++ b/backend/run.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Entry point for running the application directly.""" +import uvicorn +from config import settings + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG, + log_level="info" + ) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..336ef8f --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,252 @@ +"""Pydantic schemas for API requests and responses.""" +from datetime import datetime +from typing import List, Optional, Dict, Any, Union +from pydantic import BaseModel, Field, validator + + +# User schemas +class UserBase(BaseModel): + username: str + + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + + +class UserLogin(UserBase): + password: str + + +class UserResponse(UserBase): + id: int + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +# Token schemas +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + username: Optional[str] = None + + +# Fan curve schemas +class FanCurvePoint(BaseModel): + temp: float = Field(..., ge=0, le=150, description="Temperature in Celsius") + speed: int = Field(..., ge=0, le=100, description="Fan speed percentage") + + +class FanCurveBase(BaseModel): + name: str = "Default" + curve_data: List[FanCurvePoint] + sensor_source: str = "cpu" + is_active: bool = True + + +class FanCurveCreate(FanCurveBase): + pass + + +class FanCurveResponse(FanCurveBase): + id: int + server_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Server schemas +class ServerBase(BaseModel): + name: str + host: str + port: int = 623 + username: str + vendor: str = "dell" + + @validator('vendor') + def validate_vendor(cls, v): + allowed = ['dell', 'hpe', 'supermicro', 'other'] + if v.lower() not in allowed: + raise ValueError(f'Vendor must be one of: {allowed}') + return v.lower() + + +class ServerCreate(ServerBase): + password: str + + @validator('port') + def validate_port(cls, v): + if not 1 <= v <= 65535: + raise ValueError('Port must be between 1 and 65535') + return v + + +class ServerUpdate(BaseModel): + name: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + vendor: Optional[str] = None + manual_control_enabled: Optional[bool] = None + third_party_pcie_response: Optional[bool] = None + auto_control_enabled: Optional[bool] = None + panic_mode_enabled: Optional[bool] = None + panic_timeout_seconds: Optional[int] = None + + +class ServerResponse(ServerBase): + id: int + manual_control_enabled: bool + third_party_pcie_response: bool + auto_control_enabled: bool + panic_mode_enabled: bool + panic_timeout_seconds: int + created_at: datetime + updated_at: datetime + last_seen: Optional[datetime] + is_active: bool + + class Config: + from_attributes = True + + +class ServerDetailResponse(ServerResponse): + fan_curves: List[FanCurveResponse] = [] + + +class ServerStatusResponse(BaseModel): + server: ServerResponse + is_connected: bool + controller_status: Dict[str, Any] + + +# Sensor schemas +class SensorReading(BaseModel): + name: str + sensor_type: str + value: float + unit: str + status: str + + +class TemperatureReading(BaseModel): + name: str + location: str + value: float + status: str + + +class FanReading(BaseModel): + fan_id: str + fan_number: int + speed_rpm: Optional[int] + speed_percent: Optional[int] + + +class SensorDataResponse(BaseModel): + id: int + sensor_name: str + sensor_type: str + value: float + unit: Optional[str] + timestamp: datetime + + class Config: + from_attributes = True + + +class FanDataResponse(BaseModel): + id: int + fan_number: int + fan_id: str + speed_rpm: Optional[int] + speed_percent: Optional[int] + is_manual: bool + timestamp: datetime + + class Config: + from_attributes = True + + +class ServerSensorsResponse(BaseModel): + server_id: int + temperatures: List[TemperatureReading] + fans: List[FanReading] + all_sensors: List[SensorReading] + timestamp: datetime + + +# Fan control schemas +class FanControlCommand(BaseModel): + fan_id: str = Field(default="0xff", description="Fan ID (0xff for all, 0x00-0x07 for specific)") + speed_percent: int = Field(..., ge=0, le=100, description="Fan speed percentage") + + +class FanCurveApply(BaseModel): + curve_id: Optional[int] = None + curve_data: Optional[List[FanCurvePoint]] = None + sensor_source: Optional[str] = "cpu" + + +class AutoControlSettings(BaseModel): + enabled: bool + curve_id: Optional[int] = None + + +# System log schemas +class SystemLogResponse(BaseModel): + id: int + server_id: Optional[int] + event_type: str + message: str + details: Optional[str] + timestamp: datetime + + class Config: + from_attributes = True + + +# Setup wizard schemas +class SetupStatus(BaseModel): + setup_complete: bool + + +class SetupComplete(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + confirm_password: str = Field(..., min_length=8) + + @validator('confirm_password') + def passwords_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('Passwords do not match') + return v + + +# Dashboard schemas +class DashboardStats(BaseModel): + total_servers: int + active_servers: int + manual_control_servers: int + auto_control_servers: int + panic_mode_servers: int + recent_logs: List[SystemLogResponse] + + +class ServerDashboardData(BaseModel): + server: ServerResponse + current_temperatures: List[TemperatureReading] + current_fans: List[FanReading] + recent_sensor_data: List[SensorDataResponse] + recent_fan_data: List[FanDataResponse] + power_consumption: Optional[Dict[str, str]] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da216a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + ipmi-fan-control: + build: . + container_name: ipmi-fan-control + restart: unless-stopped + ports: + - "8000:8000" + environment: + - SECRET_KEY=${SECRET_KEY:-your-secure-secret-key-change-in-production} + - DATA_DIR=/app/data + - PANIC_TIMEOUT_SECONDS=${PANIC_TIMEOUT_SECONDS:-60} + - PANIC_FAN_SPEED=${PANIC_FAN_SPEED:-100} + volumes: + - ./data:/app/data + networks: + - ipmi-network + # Required for IPMI access to the local network + # If your servers are on a different network, adjust accordingly + extra_hosts: + - "host.docker.internal:host-gateway" + +networks: + ipmi-network: + driver: bridge diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..90c1200 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + IPMI Fan Control + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7bbd560 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "ipmi-fan-control-frontend", + "version": "1.0.0", + "private": true, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.5", + "@mui/material": "^5.15.5", + "@mui/x-charts": "^6.18.7", + "@tanstack/react-query": "^5.17.15", + "axios": "^1.6.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.2", + "recharts": "^2.10.4", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.11" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..6eb506f --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..13e05dc --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Box, CircularProgress } from '@mui/material'; +import { useAuthStore } from './stores/authStore'; +import { setupApi } from './utils/api'; + +// Pages +import SetupWizard from './pages/SetupWizard'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import ServerList from './pages/ServerList'; +import ServerDetail from './pages/ServerDetail'; +import FanCurves from './pages/FanCurves'; +import Logs from './pages/Logs'; +import Layout from './components/Layout'; + +function App() { + const { isAuthenticated, fetchUser, token } = useAuthStore(); + const [isCheckingAuth, setIsCheckingAuth] = useState(true); + const location = useLocation(); + + // Check if setup is complete + const { data: setupStatus, isLoading: isSetupLoading } = useQuery({ + queryKey: ['setup-status'], + queryFn: async () => { + const response = await setupApi.getStatus(); + return response.data; + }, + }); + + // Check auth status on mount + useEffect(() => { + const checkAuth = async () => { + if (token) { + await fetchUser(); + } + setIsCheckingAuth(false); + }; + checkAuth(); + }, [token, fetchUser]); + + if (isSetupLoading || isCheckingAuth) { + return ( + + + + ); + } + + // If setup is not complete, show setup wizard + if (!setupStatus?.setup_complete) { + return ( + + } /> + } /> + + ); + } + + // If not authenticated, show login + if (!isAuthenticated) { + return ( + + } /> + } /> + + ); + } + + // Authenticated routes + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..1215fd3 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,192 @@ +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { + Box, + Drawer, + AppBar, + Toolbar, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Typography, + IconButton, + Divider, + Avatar, + Menu, + MenuItem, +} from '@mui/material'; +import { + Dashboard as DashboardIcon, + Dns as ServerIcon, + Timeline as LogsIcon, + Menu as MenuIcon, + Logout as LogoutIcon, + AccountCircle, +} from '@mui/icons-material'; +import { useState } from 'react'; +import { useAuthStore } from '../stores/authStore'; + +const drawerWidth = 240; + +const menuItems = [ + { text: 'Dashboard', icon: , path: '/' }, + { text: 'Servers', icon: , path: '/servers' }, + { text: 'Logs', icon: , path: '/logs' }, +]; + +export default function Layout() { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuthStore(); + const [mobileOpen, setMobileOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen); + }; + + const handleProfileMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleProfileMenuClose = () => { + setAnchorEl(null); + }; + + const handleLogout = () => { + handleProfileMenuClose(); + logout(); + navigate('/login'); + }; + + const drawer = ( +
+ + + IPMI Fan Control + + + + + {menuItems.map((item) => ( + + { + navigate(item.path); + setMobileOpen(false); + }} + > + {item.icon} + + + + ))} + +
+ ); + + return ( + + + + + + + + {menuItems.find((item) => item.path === location.pathname)?.text || 'IPMI Fan Control'} + + + + + + + {user?.username} + + + + + + + Logout + + + + + + + {drawer} + + + {drawer} + + + + + + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c89bf2e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ThemeProvider, createTheme } from '@mui/material/styles' +import CssBaseline from '@mui/material/CssBaseline' +import App from './App' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +const theme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#1976d2', + }, + secondary: { + main: '#dc004e', + }, + background: { + default: '#121212', + paper: '#1e1e1e', + }, + }, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + , +) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..ef4c0ba --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,233 @@ +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Grid, + Paper, + Typography, + Card, + CardContent, + List, + ListItem, + ListItemText, + ListItemIcon, + Chip, + IconButton, + Tooltip, + CircularProgress, +} from '@mui/material'; +import { + Dns as ServerIcon, + Speed as SpeedIcon, + Warning as WarningIcon, + Error as ErrorIcon, + CheckCircle as CheckIcon, + Thermostat as TempIcon, + NavigateNext as NextIcon, +} from '@mui/icons-material'; +import { dashboardApi } from '../utils/api'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'; + +export default function Dashboard() { + const navigate = useNavigate(); + + const { data: stats, isLoading } = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: async () => { + const response = await dashboardApi.getStats(); + return response.data; + }, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + const getEventIcon = (eventType: string) => { + switch (eventType) { + case 'panic': + return ; + case 'error': + return ; + case 'warning': + return ; + default: + return ; + } + }; + + const StatCard = ({ + title, + value, + icon, + color + }: { + title: string; + value: number; + icon: React.ReactNode; + color: string; + }) => ( + + + + {icon} + + {value} + + + + {title} + + + + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + Dashboard + + + {/* Stats Cards */} + + + } + color="primary.main" + /> + + + } + color="success.main" + /> + + + } + color="info.main" + /> + + + } + color="warning.main" + /> + + + } + color="error.main" + /> + + + + {/* Recent Logs */} + + + + + Recent Events + navigate('/logs')} + clickable + /> + + + {stats?.recent_logs?.slice(0, 10).map((log) => ( + + + {getEventIcon(log.event_type)} + + + + ))} + {!stats?.recent_logs?.length && ( + + + + )} + + + + + + + + Quick Actions + + + + + + Manage Servers + + Add, edit, or remove servers + + + + navigate('/servers')}> + + + + + + + + + + View Logs + + Check system events and history + + + + navigate('/logs')}> + + + + + + + + + + About IPMI Fan Control + + + This application allows you to control fan speeds on Dell T710 and compatible servers + using IPMI commands. Features include manual fan control, automatic fan curves based + on temperature, and safety panic mode. + + + + + + + + + ); +} diff --git a/frontend/src/pages/FanCurves.tsx b/frontend/src/pages/FanCurves.tsx new file mode 100644 index 0000000..6cb8196 --- /dev/null +++ b/frontend/src/pages/FanCurves.tsx @@ -0,0 +1,481 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Box, + Typography, + Paper, + Grid, + Button, + Card, + CardContent, + List, + ListItem, + ListItemText, + ListItemButton, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + Divider, +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + PlayArrow as PlayIcon, + Stop as StopIcon, +} from '@mui/icons-material'; +import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api'; +import type { FanCurve, FanCurvePoint } from '../types'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area } from 'recharts'; + +export default function FanCurves() { + const { id } = useParams<{ id: string }>(); + const serverId = parseInt(id || '0'); + const queryClient = useQueryClient(); + + const [selectedCurve, setSelectedCurve] = useState(null); + const [openDialog, setOpenDialog] = useState(false); + const [editingCurve, setEditingCurve] = useState(null); + const [formData, setFormData] = useState({ + name: '', + sensor_source: 'cpu', + points: [ + { temp: 30, speed: 10 }, + { temp: 40, speed: 20 }, + { temp: 50, speed: 35 }, + { temp: 60, speed: 50 }, + { temp: 70, speed: 70 }, + { temp: 80, speed: 100 }, + ] as FanCurvePoint[], + }); + + const { data: server } = useQuery({ + queryKey: ['server', serverId], + queryFn: async () => { + const response = await serversApi.getById(serverId); + return response.data; + }, + }); + + const { data: curves } = useQuery({ + queryKey: ['fan-curves', serverId], + queryFn: async () => { + const response = await fanCurvesApi.getAll(serverId); + return response.data; + }, + }); + + const createMutation = useMutation({ + mutationFn: (data: { name: string; curve_data: FanCurvePoint[]; sensor_source: string; is_active: boolean }) => + fanCurvesApi.create(serverId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); + handleCloseDialog(); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ curveId, data }: { curveId: number; data: any }) => + fanCurvesApi.update(serverId, curveId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); + handleCloseDialog(); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (curveId: number) => fanCurvesApi.delete(serverId, curveId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); + if (selectedCurve?.id) { + setSelectedCurve(null); + } + }, + }); + + const enableAutoMutation = useMutation({ + mutationFn: (curveId: number) => + fanControlApi.enableAuto(serverId, { enabled: true, curve_id: curveId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['server', serverId] }); + }, + }); + + const disableAutoMutation = useMutation({ + mutationFn: () => fanControlApi.disableAuto(serverId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['server', serverId] }); + }, + }); + + const handleOpenDialog = (curve?: FanCurve) => { + if (curve) { + setEditingCurve(curve); + setFormData({ + name: curve.name, + sensor_source: curve.sensor_source, + points: curve.curve_data, + }); + } else { + setEditingCurve(null); + setFormData({ + name: '', + sensor_source: 'cpu', + points: [ + { temp: 30, speed: 10 }, + { temp: 40, speed: 20 }, + { temp: 50, speed: 35 }, + { temp: 60, speed: 50 }, + { temp: 70, speed: 70 }, + { temp: 80, speed: 100 }, + ], + }); + } + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + setEditingCurve(null); + }; + + const handleSubmit = () => { + const data = { + name: formData.name, + curve_data: formData.points, + sensor_source: formData.sensor_source, + is_active: true, + }; + + if (editingCurve) { + updateMutation.mutate({ curveId: editingCurve.id, data }); + } else { + createMutation.mutate(data); + } + }; + + const updatePoint = (index: number, field: keyof FanCurvePoint, value: number) => { + const newPoints = [...formData.points]; + newPoints[index] = { ...newPoints[index], [field]: value }; + setFormData({ ...formData, points: newPoints }); + }; + + const addPoint = () => { + setFormData({ + ...formData, + points: [...formData.points, { temp: 50, speed: 50 }], + }); + }; + + const removePoint = (index: number) => { + if (formData.points.length > 2) { + setFormData({ + ...formData, + points: formData.points.filter((_, i) => i !== index), + }); + } + }; + + return ( + + + + Fan Curves + + {server?.name} + + + + {server?.auto_control_enabled ? ( + + ) : ( + + )} + + + + + {server?.auto_control_enabled && ( + + Automatic fan control is currently active on this server. + + )} + + + {/* Curve List */} + + + + {curves?.map((curve) => ( + + { + e.stopPropagation(); + handleOpenDialog(curve); + }} + > + + + { + e.stopPropagation(); + if (confirm('Delete this fan curve?')) { + deleteMutation.mutate(curve.id); + } + }} + > + + + + } + disablePadding + > + setSelectedCurve(curve)} + > + + + {server?.auto_control_enabled && selectedCurve?.id === curve.id && ( + + )} + + } + /> + + + ))} + {!curves?.length && ( + + + + )} + + + + + {/* Curve Preview */} + + + {selectedCurve ? ( + <> + + {selectedCurve.name} + + + ({ + ...p, + label: `${p.temp}°C`, + }))} + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + + + + [ + name === 'speed' ? `${value}%` : `${value}°C`, + name === 'speed' ? 'Fan Speed' : 'Temperature', + ]} + /> + + + + + + ) : ( + + + Select a fan curve to preview + + + )} + + + + + {/* Create/Edit Dialog */} + + + {editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'} + + + setFormData({ ...formData, name: e.target.value })} + margin="normal" + /> + + Sensor Source + + + + + Curve Points + + + + {formData.points.map((point, index) => ( + + + + updatePoint(index, 'temp', parseInt(e.target.value) || 0) + } + sx={{ flex: 1 }} + /> + + updatePoint(index, 'speed', parseInt(e.target.value) || 0) + } + inputProps={{ min: 0, max: 100 }} + sx={{ flex: 1 }} + /> + + + + ))} + + + + + + + + Preview + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..d6a1d34 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + Box, + Card, + CardContent, + Typography, + TextField, + Button, + Alert, + CircularProgress, +} from '@mui/material'; +import { useAuthStore } from '../stores/authStore'; + +export default function Login() { + const navigate = useNavigate(); + const location = useLocation(); + const { login, error, clearError, isLoading } = useAuthStore(); + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + + const from = (location.state as any)?.from?.pathname || '/'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearError(); + + try { + await login(formData.username, formData.password); + navigate(from, { replace: true }); + } catch { + // Error is handled by the store + } + }; + + return ( + + + + + IPMI Fan Control + + + Sign in to manage your servers + + + {error && ( + + {error} + + )} + +
+ + setFormData({ ...formData, username: e.target.value }) + } + margin="normal" + required + disabled={isLoading} + autoFocus + /> + + setFormData({ ...formData, password: e.target.value }) + } + margin="normal" + required + disabled={isLoading} + /> + + +
+
+
+ ); +} diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..f5eba52 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Typography, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, + Pagination, +} from '@mui/material'; +import { + Error as ErrorIcon, + Warning as WarningIcon, + CheckCircle as CheckIcon, + Info as InfoIcon, + Speed as SpeedIcon, +} from '@mui/icons-material'; +import { logsApi, serversApi } from '../utils/api'; +import type { SystemLog } from '../types'; + +const LOGS_PER_PAGE = 25; + +export default function Logs() { + const [page, setPage] = useState(1); + const [eventType, setEventType] = useState(''); + const [serverFilter, setServerFilter] = useState(''); + + const { data: servers } = useQuery({ + queryKey: ['servers'], + queryFn: async () => { + const response = await serversApi.getAll(); + return response.data; + }, + }); + + const { data: logs } = useQuery({ + queryKey: ['logs', eventType, serverFilter], + queryFn: async () => { + const response = await logsApi.getAll({ + event_type: eventType || undefined, + server_id: serverFilter || undefined, + limit: 100, + }); + return response.data; + }, + }); + + const getEventIcon = (eventType: string) => { + switch (eventType) { + case 'panic': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'fan_change': + return ; + case 'info': + return ; + default: + return ; + } + }; + + const getEventColor = (eventType: string) => { + switch (eventType) { + case 'panic': + return 'error'; + case 'error': + return 'error'; + case 'warning': + return 'warning'; + case 'fan_change': + return 'info'; + case 'info': + return 'default'; + default: + return 'default'; + } + }; + + const totalPages = Math.ceil((logs?.length || 0) / LOGS_PER_PAGE); + const paginatedLogs = logs?.slice((page - 1) * LOGS_PER_PAGE, page * LOGS_PER_PAGE); + + return ( + + + System Logs + + + + + + Event Type + + + + + Server + + + + + + + + + + + Time + Server + Type + Message + Details + + + + {paginatedLogs?.map((log) => ( + + {getEventIcon(log.event_type)} + + {new Date(log.timestamp).toLocaleString()} + + + {log.server_id + ? servers?.find((s) => s.id === log.server_id)?.name || + `Server ${log.server_id}` + : 'System'} + + + + + {log.message} + {log.details} + + ))} + {!paginatedLogs?.length && ( + + + + No logs found + + + + )} + +
+
+ + {totalPages > 1 && ( + + setPage(value)} + color="primary" + /> + + )} +
+ ); +} diff --git a/frontend/src/pages/ServerDetail.tsx b/frontend/src/pages/ServerDetail.tsx new file mode 100644 index 0000000..3cb3030 --- /dev/null +++ b/frontend/src/pages/ServerDetail.tsx @@ -0,0 +1,482 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Box, + Typography, + Paper, + Grid, + Card, + CardContent, + Button, + Slider, + Alert, + Chip, + Divider, + CircularProgress, + Tabs, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Switch, + FormControlLabel, +} from '@mui/material'; +import { + Speed as SpeedIcon, + Thermostat as TempIcon, + Power as PowerIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material'; +import { serversApi, fanControlApi, dashboardApi } from '../utils/api'; +import type { TemperatureReading, FanReading } from '../types'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + return ( + + ); +} + +export default function ServerDetail() { + const { id } = useParams<{ id: string }>(); + const serverId = parseInt(id || '0'); + const queryClient = useQueryClient(); + const [tabValue, setTabValue] = useState(0); + const [fanSpeed, setFanSpeed] = useState(50); + const [selectedFan, setSelectedFan] = useState('0xff'); + + const { data: server, isLoading: isServerLoading } = useQuery({ + queryKey: ['server', serverId], + queryFn: async () => { + const response = await serversApi.getById(serverId); + return response.data; + }, + }); + + const { data: sensors, isLoading: isSensorsLoading, refetch: refetchSensors } = useQuery({ + queryKey: ['sensors', serverId], + queryFn: async () => { + const response = await serversApi.getSensors(serverId); + return response.data; + }, + refetchInterval: 5000, + }); + + const { data: dashboardData } = useQuery({ + queryKey: ['dashboard-server', serverId], + queryFn: async () => { + const response = await dashboardApi.getServerData(serverId); + return response.data; + }, + refetchInterval: 10000, + }); + + const enableManualMutation = useMutation({ + mutationFn: () => fanControlApi.enableManual(serverId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['server', serverId] }); + }, + }); + + const disableManualMutation = useMutation({ + mutationFn: () => fanControlApi.disableManual(serverId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['server', serverId] }); + }, + }); + + const setFanSpeedMutation = useMutation({ + mutationFn: ({ fanId, speed }: { fanId: string; speed: number }) => + fanControlApi.setSpeed(serverId, { fan_id: fanId, speed_percent: speed }), + }); + + const handleFanSpeedChange = (_: Event, value: number | number[]) => { + setFanSpeed(value as number); + }; + + const handleApplyFanSpeed = () => { + setFanSpeedMutation.mutate({ fanId: selectedFan, speed: fanSpeed }); + }; + + if (isServerLoading) { + return ( + + + + ); + } + + if (!server) { + return Server not found; + } + + const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || []; + const otherTemps = sensors?.temperatures.filter((t) => !t.location.startsWith('cpu')) || []; + + return ( + + + + {server.name} + + {server.host}:{server.port} • {server.vendor.toUpperCase()} + + + + {server.manual_control_enabled ? ( + } /> + ) : ( + + )} + {server.auto_control_enabled && ( + + )} + + + + setTabValue(v)}> + + + + + + + {/* Overview Tab */} + + + + + + + + CPU Temperatures + + + {cpuTemps.map((temp) => ( + + + {temp.value.toFixed(1)}°C + + {temp.name} + + + + + ))} + {cpuTemps.length === 0 && ( + + + No CPU temperature data available + + + )} + + + + + + + + + + + Fan Speeds + + + + + + Fan + RPM + Status + + + + {sensors?.fans.map((fan) => ( + + Fan {fan.fan_number} + + {fan.speed_rpm?.toLocaleString() || 'N/A'} + + + + + + ))} + +
+
+
+
+
+ + {dashboardData?.power_consumption && ( + + + + + + Power Consumption + + + {Object.entries(dashboardData.power_consumption).map(([key, value]) => ( + + + + {key} + + {value} + + + ))} + + + + + )} +
+
+ + {/* Fan Control Tab */} + + + + + + + Control Mode + + + { + if (e.target.checked) { + enableManualMutation.mutate(); + } else { + disableManualMutation.mutate(); + } + }} + /> + } + label="Manual Fan Control" + /> + + + {server.manual_control_enabled && ( + <> + + + Set Fan Speed + + + + Fan: {selectedFan === '0xff' ? 'All Fans' : `Fan ${parseInt(selectedFan, 16) + 1}`} + + `${v}%`} + /> + + + {[0, 1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + + + + )} + + + + + + + + + Safety Settings + + + } + label="Panic Mode (Auto 100% on sensor loss)" + /> + + + Timeout: {server.panic_timeout_seconds} seconds + + + } + label="3rd Party PCIe Card Response" + /> + + + + + + + + {/* Sensors Tab */} + + + + + + + All Temperature Sensors + + + + + + Sensor + Location + Value + Status + + + + {sensors?.temperatures.map((temp) => ( + + {temp.name} + {temp.location} + {temp.value.toFixed(1)}°C + + + + + ))} + +
+
+
+
+
+ + + + + + All Sensors + + + + + + Sensor + Type + Value + + + + {sensors?.all_sensors.slice(0, 20).map((sensor) => ( + + {sensor.name} + {sensor.sensor_type} + + {sensor.value} {sensor.unit} + + + ))} + +
+
+
+
+
+
+
+ + {/* Power Tab */} + + + + + + Power Information + + {dashboardData?.power_consumption ? ( + + {Object.entries(dashboardData.power_consumption).map(([key, value]) => ( + + + {value} + + {key} + + + + ))} + + ) : ( + + Power consumption data is not available for this server. + + )} + + + +
+ ); +} diff --git a/frontend/src/pages/ServerList.tsx b/frontend/src/pages/ServerList.tsx new file mode 100644 index 0000000..4c327f0 --- /dev/null +++ b/frontend/src/pages/ServerList.tsx @@ -0,0 +1,349 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Box, + Typography, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + CircularProgress, + Tooltip, +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Settings as SettingsIcon, + Speed as SpeedIcon, +} from '@mui/icons-material'; +import { serversApi } from '../utils/api'; +import type { Server } from '../types'; + +export default function ServerList() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [openDialog, setOpenDialog] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [formData, setFormData] = useState({ + name: '', + host: '', + port: 623, + username: '', + password: '', + vendor: 'dell', + }); + const [formError, setFormError] = useState(''); + + const { data: servers, isLoading } = useQuery({ + queryKey: ['servers'], + queryFn: async () => { + const response = await serversApi.getAll(); + return response.data; + }, + }); + + const createMutation = useMutation({ + mutationFn: serversApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['servers'] }); + handleCloseDialog(); + }, + onError: (error: any) => { + setFormError(error.response?.data?.detail || 'Failed to create server'); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: any }) => + serversApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['servers'] }); + handleCloseDialog(); + }, + onError: (error: any) => { + setFormError(error.response?.data?.detail || 'Failed to update server'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: serversApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['servers'] }); + }, + }); + + const handleOpenDialog = (server?: Server) => { + if (server) { + setEditingServer(server); + setFormData({ + name: server.name, + host: server.host, + port: server.port, + username: server.username, + password: '', // Don't show password + vendor: server.vendor, + }); + } else { + setEditingServer(null); + setFormData({ + name: '', + host: '', + port: 623, + username: '', + password: '', + vendor: 'dell', + }); + } + setFormError(''); + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + setEditingServer(null); + setFormError(''); + }; + + const handleSubmit = () => { + if (!formData.name || !formData.host || !formData.username) { + setFormError('Please fill in all required fields'); + return; + } + + if (!editingServer && !formData.password) { + setFormError('Password is required for new servers'); + return; + } + + if (editingServer) { + const updateData: any = { + name: formData.name, + host: formData.host, + port: formData.port, + username: formData.username, + vendor: formData.vendor, + }; + if (formData.password) { + updateData.password = formData.password; + } + updateMutation.mutate({ id: editingServer.id, data: updateData }); + } else { + createMutation.mutate(formData as any); + } + }; + + const getStatusChip = (server: Server) => { + if (server.auto_control_enabled) { + return } />; + } + if (server.manual_control_enabled) { + return ; + } + return ; + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + Servers + + + + + + + + Name + Host + Vendor + Status + Last Seen + Actions + + + + {servers?.map((server) => ( + + {server.name} + {server.host} + {server.vendor.toUpperCase()} + {getStatusChip(server)} + + {server.last_seen + ? new Date(server.last_seen).toLocaleString() + : 'Never'} + + + + navigate(`/servers/${server.id}/curves`)} + > + + + + + navigate(`/servers/${server.id}`)} + > + + + + + handleOpenDialog(server)}> + + + + + { + if (confirm('Are you sure you want to delete this server?')) { + deleteMutation.mutate(server.id); + } + }} + color="error" + > + + + + + + ))} + {!servers?.length && ( + + + + No servers added yet. Click "Add Server" to get started. + + + + )} + +
+
+ + {/* Add/Edit Dialog */} + + + {editingServer ? 'Edit Server' : 'Add Server'} + + + {formError && ( + + {formError} + + )} + setFormData({ ...formData, name: e.target.value })} + margin="normal" + required + /> + setFormData({ ...formData, host: e.target.value })} + margin="normal" + required + /> + + setFormData({ ...formData, port: parseInt(e.target.value) || 623 }) + } + margin="normal" + /> + + setFormData({ ...formData, username: e.target.value }) + } + margin="normal" + required + /> + + setFormData({ ...formData, password: e.target.value }) + } + margin="normal" + required={!editingServer} + /> + + Vendor + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx new file mode 100644 index 0000000..24c4040 --- /dev/null +++ b/frontend/src/pages/SetupWizard.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Card, + CardContent, + Typography, + TextField, + Button, + Stepper, + Step, + StepLabel, + Alert, + CircularProgress, +} from '@mui/material'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { setupApi } from '../utils/api'; + +const steps = ['Welcome', 'Create Admin Account']; + +export default function SetupWizard() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [activeStep, setActiveStep] = useState(0); + const [formData, setFormData] = useState({ + username: '', + password: '', + confirmPassword: '', + }); + const [formErrors, setFormErrors] = useState>({}); + + const setupMutation = useMutation({ + mutationFn: () => + setupApi.completeSetup(formData.username, formData.password, formData.confirmPassword), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['setup-status'] }); + navigate('/login'); + }, + }); + + const validateForm = () => { + const errors: Record = {}; + + if (!formData.username || formData.username.length < 3) { + errors.username = 'Username must be at least 3 characters'; + } + + if (!formData.password || formData.password.length < 8) { + errors.password = 'Password must be at least 8 characters'; + } + + if (formData.password !== formData.confirmPassword) { + errors.confirmPassword = 'Passwords do not match'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = () => { + if (validateForm()) { + setupMutation.mutate(); + } + }; + + const renderStepContent = () => { + switch (activeStep) { + case 0: + return ( + + + Welcome to IPMI Fan Control + + + This wizard will guide you through the initial setup of your IPMI Fan Control + application. You'll need to create an administrator account to get started. + + + Features: + +
    +
  • Control fan speeds on Dell T710 and compatible servers
  • +
  • Set custom fan curves based on temperature sensors
  • +
  • Automatic panic mode for safety
  • +
  • Monitor server health and power consumption
  • +
  • Support for multiple servers
  • +
+ +
+ ); + case 1: + return ( + + + Create Administrator Account + + + This account will have full access to manage servers and fan control settings. + + + {setupMutation.error && ( + + {(setupMutation.error as any)?.response?.data?.detail || 'Setup failed'} + + )} + + + setFormData({ ...formData, username: e.target.value }) + } + error={!!formErrors.username} + helperText={formErrors.username} + margin="normal" + disabled={setupMutation.isPending} + /> + + setFormData({ ...formData, password: e.target.value }) + } + error={!!formErrors.password} + helperText={formErrors.password} + margin="normal" + disabled={setupMutation.isPending} + /> + + setFormData({ ...formData, confirmPassword: e.target.value }) + } + error={!!formErrors.confirmPassword} + helperText={formErrors.confirmPassword} + margin="normal" + disabled={setupMutation.isPending} + /> + + + + + + + ); + default: + return null; + } + }; + + return ( + + + + + {steps.map((label) => ( + + {label} + + ))} + + {renderStepContent()} + + + + ); +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..a558905 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,73 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User } from '../types'; +import { authApi } from '../utils/api'; + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + login: (username: string, password: string) => Promise; + logout: () => void; + fetchUser: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + + login: async (username: string, password: string) => { + set({ isLoading: true, error: null }); + try { + const response = await authApi.login(username, password); + const { access_token } = response.data; + localStorage.setItem('token', access_token); + set({ token: access_token, isAuthenticated: true }); + await get().fetchUser(); + } catch (error: any) { + set({ + error: error.response?.data?.detail || 'Login failed', + isAuthenticated: false, + }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + logout: () => { + localStorage.removeItem('token'); + set({ + user: null, + token: null, + isAuthenticated: false, + error: null, + }); + }, + + fetchUser: async () => { + try { + const response = await authApi.getMe(); + set({ user: response.data, isAuthenticated: true }); + } catch (error) { + localStorage.removeItem('token'); + set({ user: null, token: null, isAuthenticated: false }); + } + }, + + clearError: () => set({ error: null }), + }), + { + name: 'auth-storage', + partialize: (state) => ({ token: state.token }), + } + ) +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..824d8e8 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,109 @@ +export interface User { + id: number; + username: string; + is_active: boolean; + created_at: string; + last_login: string | null; +} + +export interface Server { + id: number; + name: string; + host: string; + port: number; + username: string; + vendor: string; + manual_control_enabled: boolean; + third_party_pcie_response: boolean; + auto_control_enabled: boolean; + panic_mode_enabled: boolean; + panic_timeout_seconds: number; + created_at: string; + updated_at: string; + last_seen: string | null; + is_active: boolean; +} + +export interface FanCurvePoint { + temp: number; + speed: number; +} + +export interface FanCurve { + id: number; + server_id: number; + name: string; + curve_data: FanCurvePoint[]; + sensor_source: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface TemperatureReading { + name: string; + location: string; + value: number; + status: string; +} + +export interface FanReading { + fan_id: string; + fan_number: number; + speed_rpm: number | null; + speed_percent: number | null; +} + +export interface SensorReading { + name: string; + sensor_type: string; + value: number; + unit: string; + status: string; +} + +export interface SystemLog { + id: number; + server_id: number | null; + event_type: string; + message: string; + details: string | null; + timestamp: string; +} + +export interface DashboardStats { + total_servers: number; + active_servers: number; + manual_control_servers: number; + auto_control_servers: number; + panic_mode_servers: number; + recent_logs: SystemLog[]; +} + +export interface ServerStatus { + server: Server; + is_connected: boolean; + controller_status: { + is_running: boolean; + last_sensor_data: string | null; + state: string; + }; +} + +export interface ServerSensors { + server_id: number; + temperatures: TemperatureReading[]; + fans: FanReading[]; + all_sensors: SensorReading[]; + timestamp: string; +} + +export interface FanControlCommand { + fan_id: string; + speed_percent: number; +} + +export interface AutoControlSettings { + enabled: boolean; + curve_id?: number; +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..12e6f97 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,150 @@ +import axios from 'axios'; +import type { + User, + Server, + FanCurve, + FanCurvePoint, + TemperatureReading, + FanReading, + SensorReading, + SystemLog, + DashboardStats, + ServerStatus, + ServerSensors, + FanControlCommand, + AutoControlSettings, +} from '../types'; + +const API_URL = import.meta.env.VITE_API_URL || ''; + +const api = axios.create({ + baseURL: `${API_URL}/api`, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor to handle auth errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// Auth API +export const authApi = { + login: (username: string, password: string) => + api.post<{ access_token: string; token_type: string }>('/auth/login', { + username, + password, + }), + getMe: () => api.get('/auth/me'), +}; + +// Setup API +export const setupApi = { + getStatus: () => api.get<{ setup_complete: boolean }>('/setup/status'), + completeSetup: (username: string, password: string, confirmPassword: string) => + api.post('/setup/complete', { + username, + password, + confirm_password: confirmPassword, + }), +}; + +// Servers API +export const serversApi = { + getAll: () => api.get('/servers'), + getById: (id: number) => api.get(`/servers/${id}`), + create: (data: { + name: string; + host: string; + port: number; + username: string; + password: string; + vendor: string; + }) => api.post('/servers', data), + update: (id: number, data: Partial & { password?: string }) => + api.put(`/servers/${id}`, data), + delete: (id: number) => api.delete(`/servers/${id}`), + getStatus: (id: number) => api.get(`/servers/${id}/status`), + getSensors: (id: number) => api.get(`/servers/${id}/sensors`), + getPower: (id: number) => api.get>(`/servers/${id}/power`), +}; + +// Fan Control API +export const fanControlApi = { + enableManual: (serverId: number) => + api.post(`/servers/${serverId}/fans/manual/enable`), + disableManual: (serverId: number) => + api.post(`/servers/${serverId}/fans/manual/disable`), + setSpeed: (serverId: number, command: FanControlCommand) => + api.post(`/servers/${serverId}/fans/set`, command), + enableAuto: (serverId: number, settings: AutoControlSettings) => + api.post(`/servers/${serverId}/auto-control/enable`, settings), + disableAuto: (serverId: number) => + api.post(`/servers/${serverId}/auto-control/disable`), +}; + +// Fan Curves API +export const fanCurvesApi = { + getAll: (serverId: number) => + api.get(`/servers/${serverId}/fan-curves`), + create: ( + serverId: number, + data: { + name: string; + curve_data: FanCurvePoint[]; + sensor_source: string; + is_active: boolean; + } + ) => api.post(`/servers/${serverId}/fan-curves`, data), + update: ( + serverId: number, + curveId: number, + data: { + name: string; + curve_data: FanCurvePoint[]; + sensor_source: string; + is_active: boolean; + } + ) => api.put(`/servers/${serverId}/fan-curves/${curveId}`, data), + delete: (serverId: number, curveId: number) => + api.delete(`/servers/${serverId}/fan-curves/${curveId}`), +}; + +// Dashboard API +export const dashboardApi = { + getStats: () => api.get('/dashboard/stats'), + getServerData: (serverId: number) => + api.get<{ + server: Server; + current_temperatures: TemperatureReading[]; + current_fans: FanReading[]; + recent_sensor_data: SensorReading[]; + recent_fan_data: FanReading[]; + power_consumption: Record | null; + }>(`/dashboard/servers/${serverId}`), +}; + +// Logs API +export const logsApi = { + getAll: (params?: { server_id?: number; event_type?: string; limit?: number }) => + api.get('/logs', { params }), +}; + +export default api; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5413626 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d147ed3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +})