Initial commit: IPMI Fan Control application
This commit is contained in:
commit
ecc1676fe5
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
# IPMI Fan Control Backend
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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]]
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue