Initial commit: IPMI Fan Control application

This commit is contained in:
ImpulsiveFPS 2026-02-01 15:55:16 +01:00
commit ecc1676fe5
36 changed files with 5499 additions and 0 deletions

70
.dockerignore Normal file
View File

@ -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

16
.env.example Normal file
View File

@ -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

49
.gitignore vendored Normal file
View File

@ -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/

72
Dockerfile Normal file
View File

@ -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"]

237
README.md Normal file
View File

@ -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 <fan_id> <speed_hex>`
- **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 <server-ip>`
3. Test IPMI manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> 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

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# IPMI Fan Control Backend

98
backend/auth.py Normal file
View File

@ -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)

47
backend/config.py Normal file
View File

@ -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()

185
backend/database.py Normal file
View File

@ -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()

381
backend/fan_control.py Normal file
View File

@ -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()

376
backend/ipmi_client.py Normal file
View File

@ -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

831
backend/main.py Normal file
View File

@ -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)

14
backend/requirements.txt Normal file
View File

@ -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

13
backend/run.py Normal file
View File

@ -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"
)

252
backend/schemas.py Normal file
View File

@ -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]]

26
docker-compose.yml Normal file
View File

@ -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

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IPMI Fan Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/package.json Normal file
View File

@ -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"
]
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.302-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

93
frontend/src/App.tsx Normal file
View File

@ -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 (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
// If setup is not complete, show setup wizard
if (!setupStatus?.setup_complete) {
return (
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
);
}
// If not authenticated, show login
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace state={{ from: location }} />} />
</Routes>
);
}
// Authenticated routes
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/servers" element={<ServerList />} />
<Route path="/servers/:id" element={<ServerDetail />} />
<Route path="/servers/:id/curves" element={<FanCurves />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
export default App;

View File

@ -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: <DashboardIcon />, path: '/' },
{ text: 'Servers', icon: <ServerIcon />, path: '/servers' },
{ text: 'Logs', icon: <LogsIcon />, 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 | HTMLElement>(null);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleProfileMenuClose = () => {
setAnchorEl(null);
};
const handleLogout = () => {
handleProfileMenuClose();
logout();
navigate('/login');
};
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" noWrap component="div">
IPMI Fan Control
</Typography>
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => {
navigate(item.path);
setMobileOpen(false);
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</div>
);
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
ml: { md: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{menuItems.find((item) => item.path === location.pathname)?.text || 'IPMI Fan Control'}
</Typography>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleProfileMenuOpen}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleProfileMenuClose}
>
<MenuItem disabled>
<Typography variant="body2">{user?.username}</Typography>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` },
mt: 8,
}}
>
<Outlet />
</Box>
</Box>
);
}

48
frontend/src/main.tsx Normal file
View File

@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -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 <ErrorIcon color="error" />;
case 'error':
return <WarningIcon color="warning" />;
case 'warning':
return <WarningIcon color="warning" />;
default:
return <CheckIcon color="success" />;
}
};
const StatCard = ({
title,
value,
icon,
color
}: {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}) => (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Box sx={{ color, mr: 1 }}>{icon}</Box>
<Typography variant="h6" component="div">
{value}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
</CardContent>
</Card>
);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Total Servers"
value={stats?.total_servers || 0}
icon={<ServerIcon />}
color="primary.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Active Servers"
value={stats?.active_servers || 0}
icon={<CheckIcon />}
color="success.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Manual Control"
value={stats?.manual_control_servers || 0}
icon={<SpeedIcon />}
color="info.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Auto Control"
value={stats?.auto_control_servers || 0}
icon={<TempIcon />}
color="warning.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Panic Mode"
value={stats?.panic_mode_servers || 0}
icon={<ErrorIcon />}
color="error.main"
/>
</Grid>
</Grid>
{/* Recent Logs */}
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Recent Events</Typography>
<Chip
label="View All"
size="small"
onClick={() => navigate('/logs')}
clickable
/>
</Box>
<List dense>
{stats?.recent_logs?.slice(0, 10).map((log) => (
<ListItem key={log.id}>
<ListItemIcon>
{getEventIcon(log.event_type)}
</ListItemIcon>
<ListItemText
primary={log.message}
secondary={new Date(log.timestamp).toLocaleString()}
/>
</ListItem>
))}
{!stats?.recent_logs?.length && (
<ListItem>
<ListItemText
primary="No events yet"
secondary="Events will appear here when they occur"
/>
</ListItem>
)}
</List>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Quick Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Card variant="outlined">
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 1, '&:last-child': { pb: 1 } }}>
<Box>
<Typography variant="subtitle1">Manage Servers</Typography>
<Typography variant="body2" color="text.secondary">
Add, edit, or remove servers
</Typography>
</Box>
<Tooltip title="Go to Servers">
<IconButton onClick={() => navigate('/servers')}>
<NextIcon />
</IconButton>
</Tooltip>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 1, '&:last-child': { pb: 1 } }}>
<Box>
<Typography variant="subtitle1">View Logs</Typography>
<Typography variant="body2" color="text.secondary">
Check system events and history
</Typography>
</Box>
<Tooltip title="Go to Logs">
<IconButton onClick={() => navigate('/logs')}>
<NextIcon />
</IconButton>
</Tooltip>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent sx={{ py: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="subtitle1" gutterBottom>
About IPMI Fan Control
</Typography>
<Typography variant="body2" color="text.secondary">
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.
</Typography>
</CardContent>
</Card>
</Box>
</Paper>
</Grid>
</Grid>
</Box>
);
}

View File

@ -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<FanCurve | null>(null);
const [openDialog, setOpenDialog] = useState(false);
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(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 (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4">Fan Curves</Typography>
<Typography variant="body2" color="text.secondary">
{server?.name}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{server?.auto_control_enabled ? (
<Button
variant="outlined"
color="error"
startIcon={<StopIcon />}
onClick={() => disableAutoMutation.mutate()}
>
Stop Auto Control
</Button>
) : (
<Button
variant="outlined"
startIcon={<PlayIcon />}
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
disabled={!selectedCurve}
>
Start Auto Control
</Button>
)}
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
New Curve
</Button>
</Box>
</Box>
{server?.auto_control_enabled && (
<Alert severity="success" sx={{ mb: 3 }}>
Automatic fan control is currently active on this server.
</Alert>
)}
<Grid container spacing={3}>
{/* Curve List */}
<Grid item xs={12} md={4}>
<Paper>
<List>
{curves?.map((curve) => (
<ListItem
key={curve.id}
secondaryAction={
<Box>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
handleOpenDialog(curve);
}}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this fan curve?')) {
deleteMutation.mutate(curve.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
}
disablePadding
>
<ListItemButton
selected={selectedCurve?.id === curve.id}
onClick={() => setSelectedCurve(curve)}
>
<ListItemText
primary={curve.name}
secondary={
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Chip size="small" label={curve.sensor_source} />
{server?.auto_control_enabled && selectedCurve?.id === curve.id && (
<Chip size="small" color="success" label="Active" />
)}
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
{!curves?.length && (
<ListItem>
<ListItemText
primary="No fan curves yet"
secondary="Create a new curve to get started"
/>
</ListItem>
)}
</List>
</Paper>
</Grid>
{/* Curve Preview */}
<Grid item xs={12} md={8}>
<Paper sx={{ p: 3, height: 400 }}>
{selectedCurve ? (
<>
<Typography variant="h6" gutterBottom>
{selectedCurve.name}
</Typography>
<ResponsiveContainer width="100%" height="90%">
<LineChart
data={selectedCurve.curve_data.map((p) => ({
...p,
label: `${p.temp}°C`,
}))}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="temp"
label={{ value: 'Temperature (°C)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
label={{ value: 'Fan Speed (%)', angle: -90, position: 'insideLeft' }}
domain={[0, 100]}
/>
<Tooltip
formatter={(value: number, name: string) => [
name === 'speed' ? `${value}%` : `${value}°C`,
name === 'speed' ? 'Fan Speed' : 'Temperature',
]}
/>
<Area
type="monotone"
dataKey="speed"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.3}
/>
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 6 }}
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
</>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<Typography color="text.secondary">
Select a fan curve to preview
</Typography>
</Box>
)}
</Paper>
</Grid>
</Grid>
{/* Create/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
/>
<FormControl fullWidth margin="normal">
<InputLabel>Sensor Source</InputLabel>
<Select
value={formData.sensor_source}
label="Sensor Source"
onChange={(e) =>
setFormData({ ...formData, sensor_source: e.target.value })
}
>
<MenuItem value="cpu">CPU Temperature</MenuItem>
<MenuItem value="inlet">Inlet/Ambient Temperature</MenuItem>
<MenuItem value="exhaust">Exhaust Temperature</MenuItem>
<MenuItem value="highest">Highest Temperature</MenuItem>
</Select>
</FormControl>
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
Curve Points
</Typography>
<Grid container spacing={2}>
{formData.points.map((point, index) => (
<Grid item xs={12} key={index}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
type="number"
label="Temperature (°C)"
value={point.temp}
onChange={(e) =>
updatePoint(index, 'temp', parseInt(e.target.value) || 0)
}
sx={{ flex: 1 }}
/>
<TextField
type="number"
label="Fan Speed (%)"
value={point.speed}
onChange={(e) =>
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
}
inputProps={{ min: 0, max: 100 }}
sx={{ flex: 1 }}
/>
<Button
variant="outlined"
color="error"
onClick={() => removePoint(index)}
disabled={formData.points.length <= 2}
>
Remove
</Button>
</Box>
</Grid>
))}
</Grid>
<Button variant="outlined" onClick={addPoint} sx={{ mt: 2 }}>
Add Point
</Button>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Preview
</Typography>
<Box sx={{ height: 250 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={formData.points}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="temp" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={
!formData.name ||
createMutation.isPending ||
updateMutation.isPending
}
>
{editingCurve ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -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 (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'background.default',
}}
>
<Card sx={{ maxWidth: 400, width: '100%' }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom align="center">
IPMI Fan Control
</Typography>
<Typography variant="body2" color="text.secondary" align="center" paragraph>
Sign in to manage your servers
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
margin="normal"
required
disabled={isLoading}
autoFocus
/>
<TextField
fullWidth
type="password"
label="Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
margin="normal"
required
disabled={isLoading}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
sx={{ mt: 3 }}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={20} /> : null}
>
Sign In
</Button>
</form>
</CardContent>
</Card>
</Box>
);
}

197
frontend/src/pages/Logs.tsx Normal file
View File

@ -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<string>('');
const [serverFilter, setServerFilter] = useState<number | ''>('');
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 <ErrorIcon color="error" />;
case 'error':
return <ErrorIcon color="error" />;
case 'warning':
return <WarningIcon color="warning" />;
case 'fan_change':
return <SpeedIcon color="info" />;
case 'info':
return <InfoIcon color="info" />;
default:
return <CheckIcon color="success" />;
}
};
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 (
<Box>
<Typography variant="h4" gutterBottom>
System Logs
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>Event Type</InputLabel>
<Select
value={eventType}
label="Event Type"
onChange={(e) => setEventType(e.target.value)}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="panic">Panic</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="warning">Warning</MenuItem>
<MenuItem value="fan_change">Fan Change</MenuItem>
<MenuItem value="info">Info</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Server</InputLabel>
<Select
value={serverFilter}
label="Server"
onChange={(e) => setServerFilter(e.target.value as number | '')}
>
<MenuItem value="">All Servers</MenuItem>
{servers?.map((server) => (
<MenuItem key={server.id} value={server.id}>
{server.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell width={50}></TableCell>
<TableCell>Time</TableCell>
<TableCell>Server</TableCell>
<TableCell>Type</TableCell>
<TableCell>Message</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedLogs?.map((log) => (
<TableRow key={log.id} hover>
<TableCell>{getEventIcon(log.event_type)}</TableCell>
<TableCell>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>
{log.server_id
? servers?.find((s) => s.id === log.server_id)?.name ||
`Server ${log.server_id}`
: 'System'}
</TableCell>
<TableCell>
<Chip
size="small"
label={log.event_type}
color={getEventColor(log.event_type) as any}
/>
</TableCell>
<TableCell>{log.message}</TableCell>
<TableCell>{log.details}</TableCell>
</TableRow>
))}
{!paginatedLogs?.length && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
No logs found
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</Box>
);
}

View File

@ -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 (
<div hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (!server) {
return <Alert severity="error">Server not found</Alert>;
}
const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || [];
const otherTemps = sensors?.temperatures.filter((t) => !t.location.startsWith('cpu')) || [];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4">{server.name}</Typography>
<Typography variant="body2" color="text.secondary">
{server.host}:{server.port} {server.vendor.toUpperCase()}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{server.manual_control_enabled ? (
<Chip color="warning" label="Manual Control" icon={<SpeedIcon />} />
) : (
<Chip color="default" label="Automatic" />
)}
{server.auto_control_enabled && (
<Chip color="success" label="Auto Curve" />
)}
</Box>
</Box>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Overview" />
<Tab label="Fan Control" />
<Tab label="Sensors" />
<Tab label="Power" />
</Tabs>
{/* Overview Tab */}
<TabPanel value={tabValue} index={0}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
CPU Temperatures
</Typography>
<Grid container spacing={2}>
{cpuTemps.map((temp) => (
<Grid item xs={6} key={temp.name}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4">{temp.value.toFixed(1)}°C</Typography>
<Typography variant="body2" color="text.secondary">
{temp.name}
</Typography>
<Chip
size="small"
label={temp.status}
color={temp.status === 'ok' ? 'success' : 'error'}
sx={{ mt: 1 }}
/>
</Paper>
</Grid>
))}
{cpuTemps.length === 0 && (
<Grid item xs={12}>
<Typography color="text.secondary" align="center">
No CPU temperature data available
</Typography>
</Grid>
)}
</Grid>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<SpeedIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Fan Speeds
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fan</TableCell>
<TableCell align="right">RPM</TableCell>
<TableCell align="right">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sensors?.fans.map((fan) => (
<TableRow key={fan.fan_id}>
<TableCell>Fan {fan.fan_number}</TableCell>
<TableCell align="right">
{fan.speed_rpm?.toLocaleString() || 'N/A'}
</TableCell>
<TableCell align="right">
<Chip
size="small"
label={fan.speed_rpm ? 'OK' : 'Unknown'}
color={fan.speed_rpm ? 'success' : 'default'}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
{dashboardData?.power_consumption && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Power Consumption
</Typography>
<Grid container spacing={2}>
{Object.entries(dashboardData.power_consumption).map(([key, value]) => (
<Grid item xs={6} md={3} key={key}>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
{key}
</Typography>
<Typography variant="h6">{value}</Typography>
</Paper>
</Grid>
))}
</Grid>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</TabPanel>
{/* Fan Control Tab */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Control Mode
</Typography>
<Box sx={{ mb: 3 }}>
<FormControlLabel
control={
<Switch
checked={server.manual_control_enabled}
onChange={(e) => {
if (e.target.checked) {
enableManualMutation.mutate();
} else {
disableManualMutation.mutate();
}
}}
/>
}
label="Manual Fan Control"
/>
</Box>
{server.manual_control_enabled && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Set Fan Speed
</Typography>
<Box sx={{ px: 2, py: 2 }}>
<Typography gutterBottom>
Fan: {selectedFan === '0xff' ? 'All Fans' : `Fan ${parseInt(selectedFan, 16) + 1}`}
</Typography>
<Slider
value={fanSpeed}
onChange={handleFanSpeedChange}
min={0}
max={100}
step={1}
marks={[
{ value: 0, label: '0%' },
{ value: 50, label: '50%' },
{ value: 100, label: '100%' },
]}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `${v}%`}
/>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
onClick={() => setSelectedFan('0xff')}
color={selectedFan === '0xff' ? 'primary' : 'inherit'}
>
All Fans
</Button>
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<Button
key={i}
variant="outlined"
size="small"
onClick={() => setSelectedFan(`0x0${i}`)}
color={selectedFan === `0x0${i}` ? 'primary' : 'inherit'}
>
{i + 1}
</Button>
))}
</Box>
<Button
variant="contained"
fullWidth
sx={{ mt: 2 }}
onClick={handleApplyFanSpeed}
disabled={setFanSpeedMutation.isPending}
>
Apply {fanSpeed}% Speed
</Button>
</Box>
</>
)}
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Safety Settings
</Typography>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={<Switch checked={server.panic_mode_enabled} disabled />}
label="Panic Mode (Auto 100% on sensor loss)"
/>
</Box>
<Typography variant="body2" color="text.secondary">
Timeout: {server.panic_timeout_seconds} seconds
</Typography>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={<Switch checked={server.third_party_pcie_response} disabled />}
label="3rd Party PCIe Card Response"
/>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
{/* Sensors Tab */}
<TabPanel value={tabValue} index={2}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
All Temperature Sensors
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Sensor</TableCell>
<TableCell>Location</TableCell>
<TableCell align="right">Value</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sensors?.temperatures.map((temp) => (
<TableRow key={temp.name}>
<TableCell>{temp.name}</TableCell>
<TableCell>{temp.location}</TableCell>
<TableCell align="right">{temp.value.toFixed(1)}°C</TableCell>
<TableCell>
<Chip
size="small"
label={temp.status}
color={temp.status === 'ok' ? 'success' : 'error'}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
All Sensors
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Sensor</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sensors?.all_sensors.slice(0, 20).map((sensor) => (
<TableRow key={sensor.name}>
<TableCell>{sensor.name}</TableCell>
<TableCell>{sensor.sensor_type}</TableCell>
<TableCell align="right">
{sensor.value} {sensor.unit}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
{/* Power Tab */}
<TabPanel value={tabValue} index={3}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Power Information
</Typography>
{dashboardData?.power_consumption ? (
<Grid container spacing={3}>
{Object.entries(dashboardData.power_consumption).map(([key, value]) => (
<Grid item xs={12} md={4} key={key}>
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h5">{value}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{key}
</Typography>
</Paper>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">
Power consumption data is not available for this server.
</Alert>
)}
</CardContent>
</Card>
</TabPanel>
</Box>
);
}

View File

@ -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<Server | null>(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 <Chip size="small" color="success" label="Auto" icon={<SpeedIcon />} />;
}
if (server.manual_control_enabled) {
return <Chip size="small" color="warning" label="Manual" />;
}
return <Chip size="small" color="default" label="Automatic" />;
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Servers</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Add Server
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Host</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>Status</TableCell>
<TableCell>Last Seen</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servers?.map((server) => (
<TableRow key={server.id} hover>
<TableCell>{server.name}</TableCell>
<TableCell>{server.host}</TableCell>
<TableCell>{server.vendor.toUpperCase()}</TableCell>
<TableCell>{getStatusChip(server)}</TableCell>
<TableCell>
{server.last_seen
? new Date(server.last_seen).toLocaleString()
: 'Never'}
</TableCell>
<TableCell align="right">
<Tooltip title="Fan Curves">
<IconButton
onClick={() => navigate(`/servers/${server.id}/curves`)}
>
<SpeedIcon />
</IconButton>
</Tooltip>
<Tooltip title="Settings">
<IconButton
onClick={() => navigate(`/servers/${server.id}`)}
>
<SettingsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton onClick={() => handleOpenDialog(server)}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
onClick={() => {
if (confirm('Are you sure you want to delete this server?')) {
deleteMutation.mutate(server.id);
}
}}
color="error"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{!servers?.length && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
No servers added yet. Click "Add Server" to get started.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingServer ? 'Edit Server' : 'Add Server'}
</DialogTitle>
<DialogContent>
{formError && (
<Alert severity="error" sx={{ mb: 2 }}>
{formError}
</Alert>
)}
<TextField
fullWidth
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
required
/>
<TextField
fullWidth
label="IP Address / Hostname"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
margin="normal"
required
/>
<TextField
fullWidth
type="number"
label="Port"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) || 623 })
}
margin="normal"
/>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
margin="normal"
required
/>
<TextField
fullWidth
type="password"
label={editingServer ? 'Password (leave blank to keep current)' : 'Password'}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
margin="normal"
required={!editingServer}
/>
<FormControl fullWidth margin="normal">
<InputLabel>Vendor</InputLabel>
<Select
value={formData.vendor}
label="Vendor"
onChange={(e) =>
setFormData({ ...formData, vendor: e.target.value })
}
>
<MenuItem value="dell">Dell</MenuItem>
<MenuItem value="hpe">HPE</MenuItem>
<MenuItem value="supermicro">Supermicro</MenuItem>
<MenuItem value="other">Other</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending ? (
<CircularProgress size={24} />
) : editingServer ? (
'Update'
) : (
'Add'
)}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -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<Record<string, string>>({});
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<string, string> = {};
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 (
<Box>
<Typography variant="h5" gutterBottom>
Welcome to IPMI Fan Control
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
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.
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Features:
</Typography>
<ul>
<li>Control fan speeds on Dell T710 and compatible servers</li>
<li>Set custom fan curves based on temperature sensors</li>
<li>Automatic panic mode for safety</li>
<li>Monitor server health and power consumption</li>
<li>Support for multiple servers</li>
</ul>
<Button
variant="contained"
onClick={() => setActiveStep(1)}
sx={{ mt: 2 }}
>
Get Started
</Button>
</Box>
);
case 1:
return (
<Box>
<Typography variant="h5" gutterBottom>
Create Administrator Account
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This account will have full access to manage servers and fan control settings.
</Typography>
{setupMutation.error && (
<Alert severity="error" sx={{ mb: 2 }}>
{(setupMutation.error as any)?.response?.data?.detail || 'Setup failed'}
</Alert>
)}
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
error={!!formErrors.username}
helperText={formErrors.username}
margin="normal"
disabled={setupMutation.isPending}
/>
<TextField
fullWidth
type="password"
label="Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
error={!!formErrors.password}
helperText={formErrors.password}
margin="normal"
disabled={setupMutation.isPending}
/>
<TextField
fullWidth
type="password"
label="Confirm Password"
value={formData.confirmPassword}
onChange={(e) =>
setFormData({ ...formData, confirmPassword: e.target.value })
}
error={!!formErrors.confirmPassword}
helperText={formErrors.confirmPassword}
margin="normal"
disabled={setupMutation.isPending}
/>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
variant="outlined"
onClick={() => setActiveStep(0)}
disabled={setupMutation.isPending}
>
Back
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={setupMutation.isPending}
startIcon={setupMutation.isPending ? <CircularProgress size={20} /> : null}
>
Complete Setup
</Button>
</Box>
</Box>
);
default:
return null;
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'background.default',
}}
>
<Card sx={{ maxWidth: 600, width: '100%' }}>
<CardContent>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{renderStepContent()}
</CardContent>
</Card>
</Box>
);
}

View File

@ -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<void>;
logout: () => void;
fetchUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
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 }),
}
)
);

109
frontend/src/types/index.ts Normal file
View File

@ -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;
}

150
frontend/src/utils/api.ts Normal file
View File

@ -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<User>('/auth/me'),
};
// Setup API
export const setupApi = {
getStatus: () => api.get<{ setup_complete: boolean }>('/setup/status'),
completeSetup: (username: string, password: string, confirmPassword: string) =>
api.post<User>('/setup/complete', {
username,
password,
confirm_password: confirmPassword,
}),
};
// Servers API
export const serversApi = {
getAll: () => api.get<Server[]>('/servers'),
getById: (id: number) => api.get<Server>(`/servers/${id}`),
create: (data: {
name: string;
host: string;
port: number;
username: string;
password: string;
vendor: string;
}) => api.post<Server>('/servers', data),
update: (id: number, data: Partial<Server> & { password?: string }) =>
api.put<Server>(`/servers/${id}`, data),
delete: (id: number) => api.delete(`/servers/${id}`),
getStatus: (id: number) => api.get<ServerStatus>(`/servers/${id}/status`),
getSensors: (id: number) => api.get<ServerSensors>(`/servers/${id}/sensors`),
getPower: (id: number) => api.get<Record<string, string>>(`/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<FanCurve[]>(`/servers/${serverId}/fan-curves`),
create: (
serverId: number,
data: {
name: string;
curve_data: FanCurvePoint[];
sensor_source: string;
is_active: boolean;
}
) => api.post<FanCurve>(`/servers/${serverId}/fan-curves`, data),
update: (
serverId: number,
curveId: number,
data: {
name: string;
curve_data: FanCurvePoint[];
sensor_source: string;
is_active: boolean;
}
) => api.put<FanCurve>(`/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<DashboardStats>('/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<string, string> | null;
}>(`/dashboard/servers/${serverId}`),
};
// Logs API
export const logsApi = {
getAll: (params?: { server_id?: number; event_type?: string; limit?: number }) =>
api.get<SystemLog[]>('/logs', { params }),
};
export default api;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View File

@ -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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@ -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,
},
})