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