Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

94 changed files with 11215 additions and 5183 deletions

70
.dockerignore Normal file
View File

@ -0,0 +1,70 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
frontend/dist/
frontend/build/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Data (persist via volume)
data/
*.db
*.sqlite
*.sqlite3
# Environment
.env
.env.local
.env.*.local
# Testing
coverage/
htmlcov/
.pytest_cache/
.tox/
# Documentation
docs/
*.md
!README.md

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# IPMI Fan Control Configuration
# Security - CHANGE THIS IN PRODUCTION!
SECRET_KEY=your-secure-secret-key-minimum-32-characters-long
# Fan Control Settings
PANIC_TIMEOUT_SECONDS=60
PANIC_FAN_SPEED=100
# Database (default: SQLite)
# DATABASE_URL=sqlite:///app/data/ipmi_fan_control.db
# For PostgreSQL:
# DATABASE_URL=postgresql://user:password@db:5432/ipmi_fan_control
# Debug mode (disable in production)
DEBUG=false

66
.gitignore vendored
View File

@ -1,51 +1,49 @@
# Python
# Dependencies
node_modules/
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyc
*.pyo
*.pyd
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
# Build outputs
frontend/dist/
frontend/build/
*.egg-info/
.installed.cfg
*.egg
dist/
build/
# Virtual environments
venv/
env/
ENV/
# Environment
.env
.env.local
.env.*.local
# IDEs
# Data and databases
data/
*.db
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Application data
data/
*.json
!data/.gitkeep
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
htmlcov/
.pytest_cache/
.tox/
# Temp files
tmp/
temp/
*.tmp
# Docker
.docker/

71
Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# Multi-stage build for IPMI Fan Control
# Backend stage
FROM python:3.11-slim AS backend-builder
WORKDIR /app
# 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/ ./backend/
# Frontend stage
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Install dependencies
COPY frontend/package*.json ./
RUN npm install
# 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
# Set Python path to include backend directory
ENV PYTHONPATH=/app
ENV DATA_DIR=/app/data
# 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 - use absolute module path
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 IPMI Fan Control
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,99 +0,0 @@
# IPMI Controller - Persistence Setup
## Data Persistence
All configuration and user data is stored in the `data/` directory:
- `data/config.json` - All settings, fan curves, IPMI config
- `data/users.json` - User accounts and passwords
**IMPORTANT:** The `data/` directory is committed to git for version control of your settings.
## Backup Your Settings
```bash
# Create backup
cd ~/ipmi-controller
cp -r data data.backup.$(date +%Y%m%d)
# Or backup to external location
cp data/config.json /mnt/backup/ipmi-controller-config.json
```
## Auto-Start on Boot (systemd)
1. **Create service file:**
```bash
sudo tee /etc/systemd/system/ipmi-controller.service << 'EOF'
[Unit]
Description=IPMI Controller
After=network.target
[Service]
Type=simple
User=devmatrix
WorkingDirectory=/home/devmatrix/ipmi-controller
ExecStart=/usr/bin/python3 /home/devmatrix/ipmi-controller/web_server.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
```
2. **Enable and start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ipmi-controller
sudo systemctl start ipmi-controller
```
3. **Check status:**
```bash
sudo systemctl status ipmi-controller
sudo journalctl -u ipmi-controller -f
```
## Docker Deployment (Persistent)
```bash
# Using docker-compose
cd ~/ipmi-controller
docker-compose up -d
# Data is persisted in ./data directory
```
## Updating Without Losing Settings
```bash
cd ~/ipmi-controller
# Backup first
cp -r data data.backup
# Pull updates
git pull
# Restart
sudo systemctl restart ipmi-controller
# OR if using docker:
docker-compose restart
```
## What Gets Persisted
✅ IPMI connection settings
✅ HTTP sensor configuration
✅ Fan curves (Balanced, Silent, Performance, etc.)
✅ User accounts and passwords
✅ Theme preference (dark/light)
✅ Fan groups and custom names
✅ All control settings (poll interval, panic temps, etc.)
## Migration to New Server
1. Copy `data/config.json` and `data/users.json` to new server
2. Install ipmitool: `sudo apt-get install ipmitool`
3. Install Python deps: `pip install -r requirements.txt`
4. Start server: `python3 web_server.py`

501
README.md
View File

@ -1,406 +1,237 @@
# IPMI Controller
# IPMI Fan Control
Advanced web-based fan control for Dell servers with IPMI support. Automatically adjust fan speeds based on temperature readings from IPMI sensors and optional HTTP lm-sensors endpoint.
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.
**Version:** 1.0.0
**Author:** ImpulsiveFPS
**License:** MIT
---
![Dashboard](docs/dashboard.png)
## Features
- 🌡️ **Temperature Monitoring** - Real-time CPU, inlet, exhaust, and PCIe temperature monitoring
- 🌀 **Automatic Fan Control** - Dynamic fan speed adjustment based on customizable temperature curves
- 📊 **Fan Groups** - Group fans together for unified control
- 📈 **Custom Curves** - Create custom fan curves and assign them to specific fan groups
- 🖥️ **HTTP Sensors** - Optional integration with lm-sensors for additional temperature data
- 🎨 **Dark/Light Theme** - Choose your preferred visual style
- 🔒 **Secure** - Built-in authentication and session management
- 🚀 **Auto-Start** - Automatically resumes operation after system restart
- 🖥️ **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
## Table of Contents
- 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)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [IPMI Setup](#ipmi-setup)
- [HTTP Sensors Setup (Optional)](#http-sensors-setup-optional)
- [First Run](#first-run)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Support](#support)
## Quick Start
---
### Prerequisites
## Prerequisites
- Docker and Docker Compose installed
- IPMI enabled on your server(s) with network access
### Hardware Requirements
- Dell server with IPMI support (iDRAC)
- Network connectivity to the server's IPMI interface
- A machine to run the IPMI Controller (can be the same server or a separate management host)
### Software Requirements
- Python 3.8+
- `ipmitool` (for IPMI communication)
- Linux-based system (tested on Ubuntu/Debian)
### Installing ipmitool
### Installation
1. Clone the repository:
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install ipmitool
# Verify installation
ipmitool -V
git clone https://git.lemonlink.eu/impulsivefps/ipmi-fan-control.git
cd ipmi-fan-control
```
---
## Installation
### 1. Clone the Repository
2. Copy the example environment file and edit it:
```bash
git clone https://github.com/ImpulsiveFPS/IPMI-Controller.git
cd IPMI-Controller
cp .env.example .env
# Edit .env and set a secure SECRET_KEY
```
### 2. Install Python Dependencies
3. Create the data directory:
```bash
pip install -r requirements.txt
mkdir -p data
```
Required packages:
- fastapi
- uvicorn
- pydantic
- requests
### 3. Start the Application
4. Start the application:
```bash
python3 web_server.py
docker-compose up -d
```
The web interface will be available at `http://localhost:8000`
5. Access the web interface at `http://your-server-ip:8000`
### 4. (Optional) Systemd Service
6. Complete the setup wizard to create your admin account
To run the controller as a system service:
### First Time Setup
```bash
sudo cp ipmi-controller.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable ipmi-controller
sudo systemctl start ipmi-controller
```
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
## IPMI Setup
### Manual Fan Control
### Step 1: Configure iDRAC/IPMI Network Settings
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
1. Boot into your Dell server's BIOS/F2 setup
2. Navigate to **iDRAC Settings** → **Network**
3. Configure a static IP address for iDRAC (e.g., `192.168.5.191`)
4. Save and exit
### Automatic Fan Curves
Alternatively, configure via iDRAC web interface:
1. Access iDRAC at its current IP
2. Go to **iDRAC Settings****Network** → **IPV4 Settings**
3. Set static IP, subnet mask, and gateway
4. Apply changes
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
### Step 2: Create IPMI User
### Panic Mode
#### Method 1: Via iDRAC Web Interface (Recommended)
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.
1. Log into iDRAC web interface
2. Go to **iDRAC Settings****User Authentication** → **Local Users**
3. Click **Add User** or edit an existing user
4. Configure:
- **User Name:** `root` (or your preferred username)
- **Password:** Strong password
- **IPMI LAN Privilege:** Administrator
- **Enable IPMI over LAN:** ✓ Checked
5. Save changes
## IPMI Commands Used
#### Method 2: Via ipmitool (Local Access Required)
This application uses the following IPMI raw commands:
```bash
# List current users
sudo ipmitool user list 1
### Dell Servers
# Create new user (ID 3)
sudo ipmitool user set name 3 root
sudo ipmitool user set password 3 YOUR_PASSWORD
sudo ipmitool channel setaccess 1 3 callin=on ipmi=on link=on privilege=4
sudo ipmitool user enable 3
- **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`
# Verify
sudo ipmitool user list 1
```
### Standard IPMI
### Step 3: Enable IPMI over LAN
- **Get Temperatures**: `sdr type temperature`
- **Get All Sensors**: `sdr elist full`
- **Get Power Supply Status**: `sdr type 'Power Supply'`
- **Dell Power Monitor**: `delloem powermonitor`
```bash
# Enable IPMI over LAN
sudo ipmitool lan set 1 ipsrc static
sudo ipmitool lan set 1 ipaddr 192.168.5.191
sudo ipmitool lan set 1 netmask 255.255.255.0
sudo ipmitool lan set 1 defgw ipaddr 192.168.5.1
sudo ipmitool lan set 1 access on
## Fan Mapping
# Verify settings
sudo ipmitool lan print 1
```
For Dell servers, the fan mapping is:
### Step 4: Test IPMI Connection
From another machine on the network:
```bash
ipmitool -I lanplus -H 192.168.5.191 -U root -P YOUR_PASSWORD chassis status
```
If successful, you'll see server power status and other information.
---
## HTTP Sensors Setup (Optional)
HTTP sensors allow you to integrate additional temperature readings from lm-sensors running on your Proxmox host or other systems. This provides more granular CPU core temperatures.
### Step 1: Install lm-sensors on Remote Host
```bash
# On Proxmox or target host
sudo apt update
sudo apt install lm-sensors
# Detect sensors
sudo sensors-detect
# Test
sensors
```
### Step 2: Create HTTP Sensor Server Script
Create `sensor-server.py` on the remote host:
```python
#!/usr/bin/env python3
"""Simple HTTP server for lm-sensors data"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import subprocess
class SensorHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/sensors':
try:
result = subprocess.run(['sensors', '-j'],
capture_output=True, text=True)
data = json.loads(result.stdout)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress logs
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 8888), SensorHandler)
print("Sensor server running on port 8888")
server.serve_forever()
```
### Step 3: Run Sensor Server
```bash
python3 sensor-server.py
```
Or create a systemd service:
```ini
# /etc/systemd/system/sensor-server.service
[Unit]
Description=LM Sensors HTTP Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /path/to/sensor-server.py
Restart=always
User=root
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable sensor-server
sudo systemctl start sensor-server
```
### Step 4: Test HTTP Endpoint
```bash
curl http://192.168.5.200:8888/sensors
```
You should see JSON output with sensor readings.
### Step 5: Configure in IPMI Controller
1. During setup wizard (Step 3), check "Enable HTTP Sensor"
2. Enter URL: `http://192.168.5.200:8888/sensors`
3. Click "Test HTTP Sensor" to verify connection
4. Complete setup
---
## First Run
### Initial Setup Wizard
1. **Step 1: Create Admin Account**
- Username: Choose your admin username
- Password: Minimum 6 characters
- Confirm password
2. **Step 2: IPMI Configuration**
- Host/IP: Your iDRAC IP (e.g., `192.168.5.191`)
- Port: Usually `623`
- Username: IPMI username (e.g., `root`)
- Password: IPMI password
- Click "Test Connection" to verify
3. **Step 3: HTTP Sensor (Optional)**
- Enable HTTP Sensor: Check if using lm-sensors
- URL: `http://your-server:8888/sensors`
- Click "Test HTTP Sensor" to verify
4. **Enable Auto Fan Control**
- Check "Enable Auto Fan Control" to start automatic control immediately
- This activates the default "Balanced" fan curve
5. **Complete Setup**
- Click "Complete Setup" to finish
- You'll be logged in automatically
---
| 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
### Fan Curves
### Environment Variables
Fan curves define how fan speed responds to temperature:
| 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 |
1. Go to **Curves** tab
2. Click **+ Add Curve**
3. Configure:
- **Curve Name:** e.g., "Silent", "Performance"
- **Group:** (Optional) Assign to specific fan group
- **Points:** Add temperature → speed mappings
- Example: `30°C → 20%`, `50°C → 50%`, `70°C → 100%`
4. Click **Save Curve**
5. Click **Activate** to apply the curve
### Docker Compose
### Fan Groups
```yaml
version: '3.8'
Groups allow unified control of multiple fans:
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
```
1. Go to **Fan Groups** tab
2. Click **+ Create First Group**
3. Enter group name
4. Select fans to include
5. Click **Save Group**
6. Use **Set Speed** to control all fans in group
## Building from Source
### Quick Controls
### Backend
- **Start Auto:** Enable automatic fan control
- **Stop Auto:** Return to manual/BIOS control
- **Manual Speed Slider:** Set all fans to specific speed
- **Identify Fan:** Flash individual fan to 100% for identification
```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
### Connection Issues
### Cannot connect to server
**"Not connected" status:**
- Verify IPMI IP address is correct
- Check network connectivity: `ping 192.168.5.191`
- Test with ipmitool: `ipmitool -I lanplus -H 192.168.5.191 -U root chassis status`
- Ensure IPMI user has Administrator privileges
- Verify IPMI over LAN is enabled
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)
**"Connection timeout":**
- Check firewall rules on IPMI network
- Verify port 623 is open
- Try increasing timeout in settings
### Fan control not working
### 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
**Fans not responding to speed changes:**
- Some Dell servers require manual fan control to be enabled first
- Check IPMI logs for errors
- Verify fan IDs are correct
- Try using individual fan controls to test
### Container won't start
**"Manual fan control not supported":**
- Some server models don't support external fan control
- Check Dell documentation for your specific model
- Try updating iDRAC firmware
1. Check logs: `docker-compose logs -f`
2. Verify data directory has correct permissions
3. Ensure port 8000 is not already in use
### HTTP Sensor Issues
## License
**"HTTP Sensor not working":**
- Verify sensor server is running: `curl http://ip:8888/sensors`
- Check firewall on sensor host
- Ensure lm-sensors is properly configured
- Verify URL format includes `http://` prefix
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
- 🐛 **Report Bugs:** https://github.com/ImpulsiveFPS/IPMI-Controller/issues
- 📁 **GitHub Repo:** https://github.com/ImpulsiveFPS/IPMI-Controller
- ☕ **Support on Ko-fi:** https://ko-fi.com/impulsivefps
---
For issues and feature requests, please use the GitHub issue tracker.
## Acknowledgments
Built with:
- [FastAPI](https://fastapi.tiangolo.com/) - Web framework
- [Heroicons](https://heroicons.com/) - Icons
- [Lucide](https://lucide.dev/) - Additional icons
---
**IPMI Controller v1.0.0 - Built by ImpulsiveFPS**
- Dell for the IPMI command documentation
- The ipmitool project
- FastAPI and React communities

183
SETUP.md
View File

@ -1,183 +0,0 @@
# IPMI Controller - Setup Guide
## Prerequisites
- Dell server with IPMI (iDRAC) enabled
- Linux host (Ubuntu/Debian recommended)
- Python 3.10+
- ipmitool
## Installation
### 1. Install IPMI Controller
On your management server (where you run the controller):
```bash
git clone https://github.com/yourusername/ipmi-controller.git
cd ipmi-controller
pip install -r requirements.txt
```
### 2. Install ipmitool
```bash
sudo apt-get update
sudo apt-get install -y ipmitool
```
### 3. Run the Controller
```bash
python3 web_server.py
```
Open `http://your-server:8000` in browser.
## Initial Setup
1. **Complete the Setup Wizard:**
- Create admin account
- Enter IPMI credentials
- IP: Your Dell server's IPMI IP
- Username: Usually "root"
- Password: Your IPMI password
- Port: 623 (default)
2. **Login** with your new admin credentials
## lm-sensors HTTP Server (Optional but Recommended)
For better temperature monitoring (including PCIe cards), set up lm-sensors on your Dell server:
### Option A: Automated Setup
On your **Dell/Proxmox server** (not the controller):
```bash
# Download and run setup script
curl -O https://raw.githubusercontent.com/yourusername/ipmi-controller/main/setup-sensors-server.sh
chmod +x setup-sensors-server.sh
sudo ./setup-sensors-server.sh
```
### Option B: Manual Setup
1. **Install lm-sensors:**
```bash
sudo apt-get install -y lm-sensors netcat-openbsd
```
2. **Detect sensors:**
```bash
sudo sensors-detect --auto
```
3. **Test sensors:**
```bash
sensors
```
4. **Create HTTP server script** (`/usr/local/bin/sensors-http-server.sh`):
```bash
#!/bin/bash
PORT=${1:-8888}
while true; do
{
echo -e "HTTP/1.1 200 OK\r"
echo -e "Content-Type: text/plain\r"
echo -e "Access-Control-Allow-Origin: *\r"
echo -e "\r"
sensors -u 2>/dev/null || echo "Error"
} | nc -l -p "$PORT" -q 1
done
```
5. **Make executable:**
```bash
chmod +x /usr/local/bin/sensors-http-server.sh
```
6. **Create systemd service** (`/etc/systemd/system/sensors-http.service`):
```ini
[Unit]
Description=lm-sensors HTTP Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sensors-http-server.sh 8888
Restart=always
User=root
[Install]
WantedBy=multi-user.target
```
7. **Enable and start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable sensors-http
sudo systemctl start sensors-http
```
8. **Test:**
```bash
curl http://$(hostname -I | awk '{print $1}'):8888
```
### Configure IPMI Controller
1. Go to **Settings****HTTP** tab
2. Enable "HTTP Sensor"
3. Enter URL: `http://your-dell-server-ip:8888`
4. Save
## Docker Deployment
```bash
docker build -t ipmi-controller .
docker run -d \
-p 8000:8000 \
-v $(pwd)/data:/app/data \
--name ipmi-controller \
ipmi-controller
```
## Troubleshooting
### IPMI Connection Failed
- Verify IPMI IP is correct
- Check IPMI username/password
- Ensure IPMI is enabled in BIOS/iDRAC
- Test manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
### No Temperature Data
- Check if lm-sensors is installed on Dell server
- Run `sensors` to verify it works
- Check HTTP endpoint: `curl http://dell-ip:8888`
### Service Won't Start
```bash
# Check logs
sudo journalctl -u sensors-http -f
# Check if port is in use
sudo netstat -tlnp | grep 8888
```
## Security Notes
- Change default password after first login
- Use HTTPS/reverse proxy for production
- Firewall port 8000 to internal network only
- HTTP sensor endpoint is read-only
## Updating
```bash
cd ipmi-controller
git pull
pip install -r requirements.txt
# Restart the service
```

1
backend/__init__.py Normal file
View File

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

98
backend/auth.py Normal file
View File

@ -0,0 +1,98 @@
"""Authentication and security utilities."""
from datetime import datetime, timedelta
from typing import Optional, Union
import secrets
import hashlib
import base64
import logging
from jose import JWTError, jwt
from passlib.context import CryptContext
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from backend.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)

63
backend/cache.py Normal file
View File

@ -0,0 +1,63 @@
"""Simple in-memory cache for expensive operations."""
import time
import hashlib
import json
from typing import Any, Optional, Callable
import threading
class Cache:
"""Thread-safe in-memory cache with TTL."""
def __init__(self):
self._cache: dict = {}
self._lock = threading.Lock()
def get(self, key: str) -> Optional[Any]:
"""Get value from cache if not expired."""
with self._lock:
if key not in self._cache:
return None
entry = self._cache[key]
if entry['expires'] < time.time():
del self._cache[key]
return None
return entry['value']
def set(self, key: str, value: Any, ttl: int = 60):
"""Set value in cache with TTL (seconds)."""
with self._lock:
self._cache[key] = {
'value': value,
'expires': time.time() + ttl
}
def delete(self, key: str):
"""Delete key from cache."""
with self._lock:
if key in self._cache:
del self._cache[key]
def clear(self):
"""Clear all cache."""
with self._lock:
self._cache.clear()
def get_or_set(self, key: str, factory: Callable, ttl: int = 60) -> Any:
"""Get from cache or call factory and cache result."""
value = self.get(key)
if value is not None:
return value
value = factory()
self.set(key, value, ttl)
return value
# Global cache instance
cache = Cache()
def make_key(*args, **kwargs) -> str:
"""Create cache key from arguments."""
key_data = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, default=str)
return hashlib.md5(key_data.encode()).hexdigest()

47
backend/config.py Normal file
View File

@ -0,0 +1,47 @@
"""Application configuration."""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
from pydantic import Field
BASE_DIR = Path(__file__).parent
DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
class Settings(BaseSettings):
"""Application settings."""
# Security
SECRET_KEY: str = Field(default="change-me-in-production", description="Secret key for JWT")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# Database
DATABASE_URL: str = Field(default=f"sqlite:///{DATA_DIR}/ipmi_fan_control.db")
# IPMI Settings
IPMITOOL_PATH: str = Field(default="ipmitool", description="Path to ipmitool binary")
PANIC_TIMEOUT_SECONDS: int = Field(default=60, description="Seconds without sensor data before panic mode")
PANIC_FAN_SPEED: int = Field(default=100, description="Fan speed during panic mode")
# Fan Control Settings
DEFAULT_FAN_CURVE: list = Field(default=[
{"temp": 30, "speed": 10},
{"temp": 40, "speed": 20},
{"temp": 50, "speed": 35},
{"temp": 60, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 80, "speed": 100},
])
# App Settings
APP_NAME: str = "IPMI Fan Control"
DEBUG: bool = False
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

195
backend/database.py Normal file
View File

@ -0,0 +1,195 @@
"""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 backend.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)
# IPMI Settings
ipmi_host = Column(String(100), nullable=False) # IPMI IP address
ipmi_port = Column(Integer, default=623)
ipmi_username = Column(String(100), nullable=False)
ipmi_encrypted_password = Column(String(255), nullable=False) # Encrypted password
# SSH Settings (for lm-sensors)
ssh_host = Column(String(100), nullable=True) # SSH host (can be same as IPMI or different)
ssh_port = Column(Integer, default=22)
ssh_username = Column(String(100), nullable=True)
ssh_encrypted_password = Column(String(255), nullable=True) # Encrypted password or use key
ssh_key_file = Column(String(255), nullable=True) # Path to SSH key file
use_ssh = Column(Boolean, default=False) # Whether to use SSH for sensor data
# 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, index=True)
sensor_name = Column(String(100), nullable=False, index=True)
sensor_type = Column(String(50), nullable=False, index=True) # temperature, voltage, fan, power
value = Column(Float, nullable=False)
unit = Column(String(20), nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
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, index=True)
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, index=True)
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, index=True)
event_type = Column(String(50), nullable=False, index=True) # panic, fan_change, error, warning, info
message = Column(Text, nullable=False)
details = Column(Text, nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
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()

529
backend/fan_control.py Normal file
View File

@ -0,0 +1,529 @@
"""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 concurrent.futures import ThreadPoolExecutor
from sqlalchemy.orm import Session
from backend.database import (
Server, FanCurve, SensorData, FanData, SystemLog,
get_db, SessionLocal
)
from backend.ipmi_client import IPMIClient, TemperatureReading
from backend.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] = {}
self._last_sensor_data: Dict[int, datetime] = {}
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
from backend.auth import decrypt_password
# Create IPMI client
client = IPMIClient(
host=server.ipmi_host,
username=server.ipmi_username,
password=decrypt_password(server.ipmi_encrypted_password),
port=server.ipmi_port,
vendor=server.vendor
)
# Test connection with timeout
if not await asyncio.wait_for(
asyncio.to_thread(client.test_connection),
timeout=10.0
):
logger.warning(f"Cannot connect to server {server.name}")
return
# Get sensor data with timeout
temps = await asyncio.wait_for(
asyncio.to_thread(client.get_temperatures),
timeout=15.0
)
# Update last sensor data time
self._last_sensor_data[server_id] = datetime.utcnow()
server.last_seen = datetime.utcnow()
# 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()
except asyncio.TimeoutError:
logger.warning(f"Control iteration timeout for server {server_id}")
except Exception as e:
logger.error(f"Control iteration error for server {server_id}: {e}")
finally:
db.close()
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:
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:
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 await asyncio.wait_for(
asyncio.to_thread(client.enable_manual_fan_control),
timeout=10.0
):
server.manual_control_enabled = True
logger.info(f"Enabled manual fan control for {server.name}")
# Set fan speed
if await asyncio.wait_for(
asyncio.to_thread(client.set_all_fans_speed, target_speed),
timeout=10.0
):
logger.info(f"Set {server.name} fans to {target_speed}% (temp: {max_temp}°C)")
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
}
class SensorCollector:
"""High-performance background sensor data collector.
- Collects from all servers in parallel using thread pool
- Times out slow operations to prevent hanging
- Cleans up old database records periodically
- Updates cache for fast web UI access
"""
def __init__(self, max_workers: int = 4):
self.running = False
self._task: Optional[asyncio.Task] = None
self._collection_interval = 30 # seconds - IPMI is slow, need more time
self._cleanup_interval = 3600 # 1 hour
self._cache = None
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self._last_cleanup = datetime.utcnow()
self._first_collection_done = False
async def start(self):
"""Start the sensor collector."""
self.running = True
self._task = asyncio.create_task(self._collection_loop())
logger.info("Sensor collector started")
async def stop(self):
"""Stop the sensor collector."""
self.running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._executor.shutdown(wait=False)
logger.info("Sensor collector stopped")
async def _collection_loop(self):
"""Main collection loop."""
# Initial collection immediately on startup
try:
logger.info("Performing initial sensor collection...")
await self._collect_all_servers()
self._first_collection_done = True
logger.info("Initial sensor collection complete")
except Exception as e:
logger.error(f"Initial collection error: {e}")
while self.running:
try:
start_time = datetime.utcnow()
await self._collect_all_servers()
# Periodic database cleanup
if (datetime.utcnow() - self._last_cleanup).total_seconds() > self._cleanup_interval:
await self._cleanup_old_data()
# Calculate sleep time to maintain interval
elapsed = (datetime.utcnow() - start_time).total_seconds()
sleep_time = max(0, self._collection_interval - elapsed)
# Only warn if significantly over (collections can be slow)
if elapsed > self._collection_interval * 1.5:
logger.warning(f"Collection took {elapsed:.1f}s, longer than interval {self._collection_interval}s")
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Sensor collection error: {e}")
await asyncio.sleep(self._collection_interval)
async def _collect_all_servers(self):
"""Collect sensor data from all active servers in parallel."""
db = SessionLocal()
try:
servers = db.query(Server).filter(Server.is_active == True).all()
if not servers:
return
# Create tasks for parallel collection
tasks = []
for server in servers:
task = self._collect_server_with_timeout(server)
tasks.append(task)
# Run all collections concurrently with timeout protection
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results and batch store in database
all_sensor_data = []
all_fan_data = []
for server, result in zip(servers, results):
if isinstance(result, Exception):
logger.debug(f"Server {server.name} collection failed: {result}")
continue
if result:
temps, fans = result
now = datetime.utcnow()
# Prepare batch inserts
for temp in temps:
all_sensor_data.append({
'server_id': server.id,
'sensor_name': temp.name,
'sensor_type': 'temperature',
'value': temp.value,
'unit': '°C',
'timestamp': now
})
for fan in fans:
all_fan_data.append({
'server_id': server.id,
'fan_number': fan.fan_number,
'fan_id': getattr(fan, 'fan_id', str(fan.fan_number)),
'speed_rpm': fan.speed_rpm,
'speed_percent': fan.speed_percent,
'timestamp': now
})
server.last_seen = now
# Batch insert for better performance
if all_sensor_data:
db.bulk_insert_mappings(SensorData, all_sensor_data)
if all_fan_data:
db.bulk_insert_mappings(FanData, all_fan_data)
db.commit()
logger.debug(f"Collected data from {len([r for r in results if not isinstance(r, Exception)])}/{len(servers)} servers")
finally:
db.close()
async def _collect_server_with_timeout(self, server: Server) -> Optional[tuple]:
"""Collect sensor data from a single server with timeout protection."""
try:
return await asyncio.wait_for(
self._collect_server(server),
timeout=30.0 # Max 30 seconds per server (IPMI can be slow)
)
except asyncio.TimeoutError:
logger.warning(f"Collection timeout for {server.name}")
return None
async def _collect_server(self, server: Server) -> Optional[tuple]:
"""Collect sensor data from a single server."""
try:
from backend.auth import decrypt_password
from backend.main import sensor_cache
# Run blocking IPMI operations in thread pool
loop = asyncio.get_event_loop()
client = IPMIClient(
host=server.ipmi_host,
username=server.ipmi_username,
password=decrypt_password(server.ipmi_encrypted_password),
port=server.ipmi_port,
vendor=server.vendor
)
# Test connection
connected = await loop.run_in_executor(self._executor, client.test_connection)
if not connected:
return None
# Get sensor data in parallel using thread pool
temps_future = loop.run_in_executor(self._executor, client.get_temperatures)
fans_future = loop.run_in_executor(self._executor, client.get_fan_speeds)
power_future = loop.run_in_executor(self._executor, client.get_power_consumption)
temps, fans, power = await asyncio.gather(
temps_future, fans_future, power_future
)
# Calculate summary metrics
max_temp = max((t.value for t in temps if t.value is not None), default=0)
avg_fan = sum(f.speed_percent for f in fans if f.speed_percent is not None) / len(fans) if fans else 0
# Extract current power consumption
current_power = None
if power and isinstance(power, dict):
import re
for key, value in power.items():
if 'current' in key.lower() and 'power' in key.lower():
match = re.search(r'(\d+(?:\.\d+)?)', str(value))
if match:
current_power = float(match.group(1))
break
# Prepare cache data - format must match response schemas
cache_data = {
"max_temp": max_temp,
"avg_fan_speed": round(avg_fan, 1),
"power_consumption": current_power,
"timestamp": datetime.utcnow().isoformat(),
"temps": [{"name": t.name, "value": t.value, "location": t.location, "status": getattr(t, 'status', 'ok')} for t in temps],
"fans": [{"fan_id": getattr(f, 'fan_id', f'0x0{f.fan_number-1}'), "fan_number": f.fan_number, "speed_percent": f.speed_percent, "speed_rpm": f.speed_rpm} for f in fans],
"power_raw": power if isinstance(power, dict) else None
}
# Store in cache
await sensor_cache.set(server.id, cache_data)
logger.info(f"Collected and cached sensors for {server.name}: temp={max_temp:.1f}°C, fan={avg_fan:.1f}%")
return temps, fans
except Exception as e:
logger.warning(f"Failed to collect sensors for {server.name}: {e}")
return None
async def _cleanup_old_data(self):
"""Clean up old sensor data to prevent database bloat."""
try:
db = SessionLocal()
try:
# Keep only last 24 hours of detailed sensor data
cutoff = datetime.utcnow() - timedelta(hours=24)
# Delete old sensor data
deleted_sensors = db.query(SensorData).filter(
SensorData.timestamp < cutoff
).delete(synchronize_session=False)
# Delete old fan data
deleted_fans = db.query(FanData).filter(
FanData.timestamp < cutoff
).delete(synchronize_session=False)
db.commit()
if deleted_sensors > 0 or deleted_fans > 0:
logger.info(f"Cleaned up {deleted_sensors} sensor records and {deleted_fans} fan records")
self._last_cleanup = datetime.utcnow()
finally:
db.close()
except Exception as e:
logger.error(f"Database cleanup failed: {e}")
# Global controller instance
fan_controller = FanController()
sensor_collector = SensorCollector(max_workers=4)
async def initialize_fan_controller():
"""Initialize and start the fan controller and sensor collector."""
await sensor_collector.start()
await fan_controller.start()
async def shutdown_fan_controller():
"""Shutdown the fan controller and sensor collector."""
await fan_controller.stop()
await sensor_collector.stop()

376
backend/ipmi_client.py Normal file
View File

@ -0,0 +1,376 @@
"""IPMI client for communicating with servers."""
import subprocess
import re
import json
import logging
from typing import List, Dict, Optional, Tuple, Any
from dataclasses import dataclass
from datetime import datetime
from backend.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

1152
backend/main.py Normal file

File diff suppressed because it is too large Load Diff

17
backend/requirements.txt Normal file
View File

@ -0,0 +1,17 @@
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
bcrypt==4.0.1
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
cryptography==42.0.0
asyncssh==2.14.2

13
backend/run.py Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Entry point for running the application directly."""
import uvicorn
from backend.config import settings
if __name__ == "__main__":
uvicorn.run(
"backend.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
log_level="info"
)

283
backend/schemas.py Normal file
View File

@ -0,0 +1,283 @@
"""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
@validator('curve_data', pre=True)
def parse_curve_data(cls, v):
"""Parse curve_data from JSON string if needed."""
if isinstance(v, str):
import json
return json.loads(v)
return v
# IPMI Settings schema
class IPMISettings(BaseModel):
ipmi_host: str = Field(..., description="IPMI IP address or hostname")
ipmi_port: int = Field(623, description="IPMI port (default 623)")
ipmi_username: str = Field(..., description="IPMI username")
ipmi_password: str = Field(..., description="IPMI password")
# SSH Settings schema
class SSHSettings(BaseModel):
use_ssh: bool = Field(False, description="Enable SSH for sensor data collection")
ssh_host: Optional[str] = Field(None, description="SSH host (leave empty to use IPMI host)")
ssh_port: int = Field(22, description="SSH port (default 22)")
ssh_username: Optional[str] = Field(None, description="SSH username")
ssh_password: Optional[str] = Field(None, description="SSH password (or use key)")
ssh_key_file: Optional[str] = Field(None, description="Path to SSH private key file")
# Server schemas
class ServerBase(BaseModel):
name: str = Field(..., description="Server name/display name")
vendor: str = Field("dell", description="Server vendor (dell, hpe, supermicro, other)")
@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):
ipmi: IPMISettings
ssh: SSHSettings = Field(default_factory=lambda: SSHSettings(use_ssh=False))
class ServerUpdate(BaseModel):
name: Optional[str] = None
ipmi_host: Optional[str] = None
ipmi_port: Optional[int] = None
ipmi_username: Optional[str] = None
ipmi_password: Optional[str] = None
ssh_host: Optional[str] = None
ssh_port: Optional[int] = None
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_key_file: Optional[str] = None
use_ssh: Optional[bool] = 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
ipmi_host: str
ipmi_port: int
ipmi_username: str
ssh_host: Optional[str]
ssh_port: int
ssh_username: Optional[str]
use_ssh: bool
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]]

247
backend/ssh_client.py Normal file
View File

@ -0,0 +1,247 @@
"""SSH client for connecting to servers to get lm-sensors data."""
import asyncio
import json
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
import asyncssh
logger = logging.getLogger(__name__)
@dataclass
class SensorData:
"""Sensor data from lm-sensors."""
name: str
adapter: str
values: Dict[str, float]
unit: str
@dataclass
class CPUTemp:
"""CPU temperature data."""
cpu_name: str
core_temps: Dict[str, float]
package_temp: Optional[float]
class SSHClient:
"""SSH client for server sensor monitoring."""
def __init__(self, host: str, username: str, password: Optional[str] = None,
port: int = 22, key_file: Optional[str] = None):
self.host = host
self.username = username
self.password = password
self.port = port
self.key_file = key_file
self._conn = None
async def connect(self) -> bool:
"""Connect to the server via SSH."""
try:
conn_options = {}
if self.password:
conn_options['password'] = self.password
if self.key_file:
conn_options['client_keys'] = [self.key_file]
self._conn = await asyncssh.connect(
self.host,
port=self.port,
username=self.username,
known_hosts=None, # Allow unknown hosts (use with caution)
**conn_options
)
logger.info(f"SSH connected to {self.host}")
return True
except Exception as e:
logger.error(f"SSH connection failed to {self.host}: {e}")
return False
async def disconnect(self):
"""Disconnect from the server."""
if self._conn:
self._conn.close()
await self._conn.wait_closed()
self._conn = None
async def test_connection(self) -> bool:
"""Test SSH connection."""
if not self._conn:
if not await self.connect():
return False
try:
result = await self._conn.run('echo "test"', check=True)
return result.exit_status == 0
except Exception as e:
logger.error(f"SSH test failed: {e}")
return False
async def get_lmsensors_data(self) -> Optional[Dict[str, Any]]:
"""Get sensor data from lm-sensors."""
if not self._conn:
if not await self.connect():
return None
try:
# Check if sensors command exists
result = await self._conn.run('which sensors', check=False)
if result.exit_status != 0:
logger.warning("lm-sensors not installed on remote server")
return None
# Get sensor data in JSON format
result = await self._conn.run('sensors -j', check=False)
if result.exit_status == 0:
return json.loads(result.stdout)
else:
# Try without JSON format
result = await self._conn.run('sensors', check=False)
if result.exit_status == 0:
return self._parse_sensors_text(result.stdout)
return None
except Exception as e:
logger.error(f"Failed to get lm-sensors data: {e}")
return None
def _parse_sensors_text(self, output: str) -> Dict[str, Any]:
"""Parse plain text sensors output."""
data = {}
current_adapter = None
for line in output.split('\n'):
line = line.strip()
if not line:
continue
# Adapter line
if line.startswith('Adapter:'):
current_adapter = line.replace('Adapter:', '').strip()
continue
# Chip header
if ':' not in line and line:
current_chip = line
if current_chip not in data:
data[current_chip] = {}
if current_adapter:
data[current_chip]['Adapter'] = current_adapter
continue
# Sensor value
if ':' in line and current_chip in data:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value_str = parts[1].strip()
# Try to extract numeric value
try:
# Remove units and extract number
value_clean = ''.join(c for c in value_str if c.isdigit() or c == '.' or c == '-')
if value_clean:
data[current_chip][key] = float(value_clean)
except ValueError:
data[current_chip][key] = value_str
return data
async def get_cpu_temperatures(self) -> List[CPUTemp]:
"""Get CPU temperatures from lm-sensors."""
sensors_data = await self.get_lmsensors_data()
if not sensors_data:
return []
cpu_temps = []
for chip_name, chip_data in sensors_data.items():
# Look for coretemp or k10temp (AMD) chips
if 'coretemp' in chip_name.lower() or 'k10temp' in chip_name.lower():
core_temps = {}
package_temp = None
for key, value in chip_data.items():
# Skip metadata fields
if key in ['Adapter']:
continue
# Handle nested JSON structure from sensors -j
# e.g., "Core 0": {"temp2_input": 31, "temp2_max": 79, ...}
if isinstance(value, dict):
# Look for temp*_input field which contains the actual temperature
for sub_key, sub_value in value.items():
if 'input' in sub_key.lower() and isinstance(sub_value, (int, float)):
temp_value = float(sub_value)
if 'core' in key.lower():
core_temps[key] = temp_value
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
package_temp = temp_value
break # Only take the first _input value
# Handle flat structure (fallback for text parsing)
elif isinstance(value, (int, float)):
if 'core' in key.lower():
core_temps[key] = float(value)
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
package_temp = float(value)
if core_temps or package_temp:
cpu_temps.append(CPUTemp(
cpu_name=chip_name,
core_temps=core_temps,
package_temp=package_temp
))
return cpu_temps
async def get_system_info(self) -> Optional[Dict[str, str]]:
"""Get basic system information."""
if not self._conn:
if not await self.connect():
return None
try:
info = {}
# CPU info
result = await self._conn.run('cat /proc/cpuinfo | grep "model name" | head -1', check=False)
if result.exit_status == 0:
info['cpu'] = result.stdout.split(':')[1].strip() if ':' in result.stdout else 'Unknown'
# Memory info
result = await self._conn.run('free -h | grep Mem', check=False)
if result.exit_status == 0:
parts = result.stdout.split()
if len(parts) >= 2:
info['memory'] = parts[1]
# OS info
result = await self._conn.run('cat /etc/os-release | grep PRETTY_NAME', check=False)
if result.exit_status == 0:
info['os'] = result.stdout.split('=')[1].strip().strip('"')
# Uptime
result = await self._conn.run('uptime -p', check=False)
if result.exit_status == 0:
info['uptime'] = result.stdout.strip()
return info
except Exception as e:
logger.error(f"Failed to get system info: {e}")
return None
async def execute_command(self, command: str) -> tuple[int, str, str]:
"""Execute a custom command on the server."""
if not self._conn:
if not await self.connect():
return -1, "", "Not connected"
try:
result = await self._conn.run(command, check=False)
return result.exit_status, result.stdout, result.stderr
except Exception as e:
logger.error(f"Command execution failed: {e}")
return -1, "", str(e)

141
backup.sh
View File

@ -1,141 +0,0 @@
#!/bin/bash
# IPMI Controller - Backup and Restore
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA_DIR="${DATA_DIR:-$SCRIPT_DIR/data}"
BACKUP_DIR="${BACKUP_DIR:-$SCRIPT_DIR/backups}"
show_help() {
echo "IPMI Controller - Backup/Restore Tool"
echo ""
echo "Usage:"
echo " $0 backup - Create backup"
echo " $0 restore [filename] - Restore from backup"
echo " $0 list - List available backups"
echo " $0 auto - Auto-backup (cron-friendly)"
echo ""
}
create_backup() {
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/ipmi-controller-backup-$TIMESTAMP.tar.gz"
echo "📦 Creating backup..."
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
if [ $? -eq 0 ]; then
echo "✅ Backup created: $BACKUP_FILE"
echo ""
ls -lh "$BACKUP_FILE"
else
echo "❌ Backup failed"
exit 1
fi
}
restore_backup() {
if [ -z "$1" ]; then
echo "❌ Please specify backup file"
list_backups
exit 1
fi
BACKUP_FILE="$BACKUP_DIR/$1"
if [ ! -f "$BACKUP_FILE" ]; then
BACKUP_FILE="$1"
fi
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ Backup file not found: $1"
exit 1
fi
echo "⚠️ This will overwrite current settings!"
read -p "Are you sure? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted"
exit 0
fi
# Create safety backup first
echo "📦 Creating safety backup..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
tar -czf "$BACKUP_DIR/safety-backup-before-restore-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data/ 2>/dev/null
# Stop service if running
if systemctl is-active --quiet ipmi-controller 2>/dev/null; then
echo "🛑 Stopping service..."
sudo systemctl stop ipmi-controller
WAS_RUNNING=true
else
WAS_RUNNING=false
fi
# Restore
echo "📥 Restoring from backup..."
tar -xzf "$BACKUP_FILE" -C "$SCRIPT_DIR"
if [ $? -eq 0 ]; then
echo "✅ Restore complete"
# Restart service if it was running
if [ "$WAS_RUNNING" = true ]; then
echo "🚀 Starting service..."
sudo systemctl start ipmi-controller
fi
else
echo "❌ Restore failed"
exit 1
fi
}
list_backups() {
echo "📂 Available backups:"
echo ""
if [ -d "$BACKUP_DIR" ] && [ "$(ls -A "$BACKUP_DIR")" ]; then
ls -lh "$BACKUP_DIR"/*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
else
echo " No backups found"
fi
}
auto_backup() {
# This is cron-friendly - keeps only last 30 days
mkdir -p "$BACKUP_DIR"
# Create backup
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/auto-backup-$TIMESTAMP.tar.gz"
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
# Clean old backups (keep last 30 days)
find "$BACKUP_DIR" -name "auto-backup-*.tar.gz" -mtime +30 -delete 2>/dev/null
echo "Auto-backup complete: $BACKUP_FILE"
}
case "${1:-}" in
backup)
create_backup
;;
restore)
restore_backup "$2"
;;
list)
list_backups
;;
auto)
auto_backup
;;
help|--help|-h)
show_help
;;
*)
show_help
exit 1
;;
esac

View File

@ -1,167 +0,0 @@
{
"ipmi_host": "192.168.5.191",
"ipmi_username": "root",
"ipmi_password": "calvin",
"ipmi_port": 623,
"http_sensor_enabled": true,
"http_sensor_url": "http://192.168.5.200:8888",
"http_sensor_timeout": 10,
"enabled": true,
"poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10,
"max_speed": 100,
"panic_temp": 85,
"panic_speed": 100,
"panic_on_no_data": true,
"no_data_timeout": 60,
"primary_sensor": "cpu",
"sensor_preference": "auto",
"fans": {},
"fan_groups": {
"1 & 2": {
"fan_ids": [
"0x00",
"0x01"
]
}
},
"active_curve": "Balanced",
"fan_curves": {
"Balanced": {
"points": [
{
"temp": 30,
"speed": 10
},
{
"temp": 35,
"speed": 12
},
{
"temp": 40,
"speed": 15
},
{
"temp": 45,
"speed": 20
},
{
"temp": 50,
"speed": 30
},
{
"temp": 55,
"speed": 40
},
{
"temp": 60,
"speed": 55
},
{
"temp": 65,
"speed": 70
},
{
"temp": 70,
"speed": 85
},
{
"temp": 75,
"speed": 95
},
{
"temp": 80,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{
"temp": 30,
"speed": 5
},
{
"temp": 40,
"speed": 10
},
{
"temp": 50,
"speed": 15
},
{
"temp": 55,
"speed": 25
},
{
"temp": 60,
"speed": 35
},
{
"temp": 65,
"speed": 50
},
{
"temp": 70,
"speed": 70
},
{
"temp": 75,
"speed": 85
},
{
"temp": 80,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{
"temp": 30,
"speed": 20
},
{
"temp": 35,
"speed": 25
},
{
"temp": 40,
"speed": 35
},
{
"temp": 45,
"speed": 45
},
{
"temp": 50,
"speed": 55
},
{
"temp": 55,
"speed": 70
},
{
"temp": 60,
"speed": 85
},
{
"temp": 65,
"speed": 95
},
{
"temp": 70,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
"theme": "dark"
}

View File

@ -1 +0,0 @@
{"users": {"admin": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae"}}

View File

@ -1,46 +0,0 @@
#!/bin/bash
# Deploy IPMI Controller to production
set -e
PROD_HOST="192.168.5.211"
PROD_USER="devmatrix"
PROD_DIR="/opt/ipmi-controller"
SERVICE_NAME="ipmi-controller"
echo "=== Deploying IPMI Controller to Production ==="
echo "Target: $PROD_HOST"
# Install system dependencies
echo "Installing system dependencies..."
ssh $PROD_USER@$PROD_HOST "sudo apt update -qq && sudo apt install -y ipmitool python3-pip 2>/dev/null | tail -3"
# Create remote directory
echo "Creating remote directory..."
ssh $PROD_USER@$PROD_HOST "sudo mkdir -p $PROD_DIR && sudo chown $PROD_USER:$PROD_USER $PROD_DIR"
# Copy files
echo "Copying files..."
rsync -avz --exclude='.git' --exclude='__pycache__' --exclude='data/*.json' \
./ $PROD_USER@$PROD_HOST:$PROD_DIR/
# Install systemd service
echo "Installing systemd service..."
ssh $PROD_USER@$PROD_HOST "sudo cp $PROD_DIR/ipmi-controller.service /etc/systemd/system/"
ssh $PROD_USER@$PROD_HOST "sudo systemctl daemon-reload"
ssh $PROD_USER@$PROD_HOST "sudo systemctl enable $SERVICE_NAME"
# Restart service
echo "Restarting service..."
ssh $PROD_USER@$PROD_HOST "sudo systemctl restart $SERVICE_NAME"
# Check status
echo "Checking service status..."
sleep 2
ssh $PROD_USER@$PROD_HOST "sudo systemctl status $SERVICE_NAME --no-pager"
echo ""
echo "=== Deployment Complete ==="
echo "IPMI Controller is now running at: http://$PROD_HOST:8000"
echo ""
echo "To check logs: ssh $PROD_USER@$PROD_HOST 'sudo journalctl -u $SERVICE_NAME -f'"

View File

@ -1,20 +1,26 @@
version: '3.8'
services:
ipmi-controller:
ipmi-fan-control:
build: .
container_name: ipmi-controller
container_name: ipmi-fan-control
restart: unless-stopped
ports:
- "8000:8000"
volumes:
# Persist data directory
- ./data:/app/data
# Optional: mount ipmitool from host if needed
- /usr/bin/ipmitool:/usr/bin/ipmitool:ro
environment:
- PYTHONUNBUFFERED=1
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
- DATA_DIR=/app/data
# Required for ipmitool to work in container
privileged: true
network_mode: host
- PANIC_TIMEOUT_SECONDS=${PANIC_TIMEOUT_SECONDS:-60}
- PANIC_FAN_SPEED=${PANIC_FAN_SPEED:-100}
- DEBUG=${DEBUG:-true}
- PYTHONUNBUFFERED=1
volumes:
- ./data:/app/data
networks:
- ipmi-network
# For Windows Docker Desktop, we need this for proper shutdown
stop_grace_period: 10s
networks:
ipmi-network:
driver: bridge

27
docker-config/nginx.conf Normal file
View File

@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
location / {
proxy_pass http://ipmi-fan-control:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api {
proxy_pass http://ipmi-fan-control:8000/api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -1,800 +0,0 @@
"""
IPMI Controller - Advanced Fan Control for Dell Servers
Features: Fan groups, multiple curves, HTTP sensors, panic mode
"""
import subprocess
import re
import time
import json
import logging
import threading
import requests
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from datetime import datetime
from pathlib import Path
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('/tmp/ipmi-controller.log')
]
)
logger = logging.getLogger(__name__)
@dataclass
class TemperatureReading:
name: str
location: str
value: float
status: str
source: str = "ipmi" # ipmi, http, ssh
@dataclass
class FanReading:
fan_id: str
fan_number: int
speed_rpm: Optional[int]
speed_percent: Optional[int]
name: Optional[str] = None # Custom name
group: Optional[str] = None # Fan group
@dataclass
class FanCurve:
name: str
points: List[Dict[str, float]] # [{"temp": 30, "speed": 15}, ...]
sensor_source: str = "cpu" # Which sensor to use
applies_to: str = "all" # "all", group name, or fan_id
class HTTPSensorClient:
"""Client for fetching sensor data from HTTP endpoint (lm-sensors over HTTP)."""
def __init__(self, url: str, timeout: int = 10):
self.url = url
self.timeout = timeout
self.last_reading = None
self.consecutive_failures = 0
def fetch_sensors(self) -> List[TemperatureReading]:
"""Fetch sensor data from HTTP endpoint."""
try:
response = requests.get(self.url, timeout=self.timeout)
response.raise_for_status()
# Parse lm-sensors style output
temps = self._parse_sensors_output(response.text)
self.consecutive_failures = 0
return temps
except Exception as e:
logger.error(f"Failed to fetch HTTP sensors from {self.url}: {e}")
self.consecutive_failures += 1
return []
def _parse_sensors_output(self, output: str) -> List[TemperatureReading]:
"""Parse lm-sensors -u style output."""
temps = []
current_chip = ""
for line in output.splitlines():
line = line.strip()
# New chip section - chip names typically don't have spaces or colons at start
if line and not line.startswith("_") and ":" not in line and not line[0].isdigit():
if "Adapter:" not in line and "ERROR" not in line.upper():
current_chip = line
continue
# Temperature reading
if "_input:" in line and "temp" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
name = parts[0].strip()
try:
value = float(parts[1].strip().split()[0]) # Handle "34.000" or "34.000 (high ="
location = self._classify_sensor_name(name, current_chip)
temps.append(TemperatureReading(
name=f"{current_chip}/{name}",
location=location,
value=value,
status="ok",
source="http"
))
except (ValueError, IndexError):
pass
return temps
def _classify_sensor_name(self, name: str, chip: str) -> str:
"""Classify sensor location from name with detailed categories."""
import re
name_lower = name.lower()
chip_lower = chip.lower()
# Check chip name first for CPU identification
if "coretemp" in chip_lower:
# Extract CPU number from chip (coretemp-isa-0000 = cpu1, coretemp-isa-0001 = cpu2)
if "0001" in chip or "isa-0001" in chip_lower:
return "cpu2"
return "cpu1"
# Check sensor name for core temps
if "core" in name_lower:
# Try to determine which CPU based on core number
core_match = re.search(r'core\s*(\d+)', name_lower)
if core_match:
core_num = int(core_match.group(1))
if core_num >= 6:
return "cpu2"
return "cpu1"
return "cpu"
elif "package" in name_lower:
return "cpu"
elif "tdie" in name_lower or "tctl" in name_lower:
return "cpu"
elif "nvme" in name_lower or "composite" in name_lower:
return "nvme"
elif "raid" in name_lower or "megaraid" in name_lower:
return "raid"
elif "pcie" in name_lower:
return "pcie"
elif "inlet" in name_lower or "ambient" in name_lower or "room" in name_lower:
return "ambient"
elif "exhaust" in name_lower or "outlet" in name_lower:
return "exhaust"
elif "inlet" in name_lower:
return "inlet"
elif "loc1" in name_lower or "loc2" in name_lower or "chipset" in name_lower:
return "chipset"
return "other"
def is_healthy(self) -> bool:
return self.consecutive_failures < 3
class IPMIFanController:
"""IPMI fan controller with advanced features."""
def __init__(self, host: str, username: str, password: str, port: int = 623):
self.host = host
self.username = username
self.password = password
self.port = port
self.manual_mode = False
self.last_successful_read = None
self.consecutive_failures = 0
self.max_failures = 5
def _run_ipmi(self, args: List[str], timeout: int = 15) -> Tuple[bool, str]:
"""Run IPMI command with error handling."""
cmd = [
"ipmitool", "-I", "lanplus",
"-H", self.host,
"-U", self.username,
"-P", self.password,
"-p", str(self.port)
] + args
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode == 0:
self.consecutive_failures = 0
return True, result.stdout
else:
self.consecutive_failures += 1
logger.warning(f"IPMI command failed: {result.stderr}")
return False, result.stderr
except subprocess.TimeoutExpired:
self.consecutive_failures += 1
logger.error(f"IPMI command timed out after {timeout}s")
return False, "Timeout"
except Exception as e:
self.consecutive_failures += 1
logger.error(f"IPMI command error: {e}")
return False, str(e)
def test_connection(self) -> bool:
"""Test IPMI connection."""
success, _ = self._run_ipmi(["mc", "info"], timeout=10)
return success
def enable_manual_fan_control(self) -> bool:
"""Enable manual fan control mode."""
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
if success:
self.manual_mode = True
logger.info("Manual fan control enabled")
return success
def disable_manual_fan_control(self) -> bool:
"""Return to automatic fan control."""
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
if success:
self.manual_mode = False
logger.info("Automatic fan control restored")
return success
def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool:
"""Set fan speed (0-100%). fan_id 0xff = all fans."""
speed_percent = max(0, min(100, speed_percent))
hex_speed = f"0x{speed_percent:02x}"
success, _ = self._run_ipmi([
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
])
if success:
logger.info(f"Fan {fan_id} speed set to {speed_percent}%")
return success
def get_temperatures(self) -> List[TemperatureReading]:
"""Get temperature readings from all sensors."""
success, output = self._run_ipmi(["sdr", "type", "temperature"])
if not success:
return []
temps = []
for line in output.splitlines():
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]
match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE)
if match:
value = float(match.group(1))
location = self._classify_temp_location(name)
temps.append(TemperatureReading(
name=name,
location=location,
value=value,
status=status,
source="ipmi"
))
return temps
def get_fan_speeds(self) -> List[FanReading]:
"""Get current fan speeds."""
success, output = self._run_ipmi(["sdr", "elist", "full"])
if not success:
return []
fans = []
for line in output.splitlines():
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]
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
fan_number = int(match.group(1)) if match else 0
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00"
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
))
return fans
def _classify_temp_location(self, name: str) -> str:
"""Classify temperature sensor location."""
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 "memory" in name_lower or "dimm" in name_lower:
return "memory"
return "other"
def is_healthy(self) -> bool:
"""Check if controller is working properly."""
return self.consecutive_failures < self.max_failures
class IPMIControllerService:
"""Main service for IPMI Controller with all advanced features."""
def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"):
self.config_path = config_path
self.controller: Optional[IPMIFanController] = None
self.http_client: Optional[HTTPSensorClient] = None
self.running = False
self.thread: Optional[threading.Thread] = None
self.current_speeds: Dict[str, int] = {} # fan_id -> speed
self.target_speeds: Dict[str, int] = {}
self.last_temps: List[TemperatureReading] = []
self.last_fans: List[FanReading] = []
self.lock = threading.Lock()
self.in_identify_mode = False
# Default config
self.config = {
# IPMI Settings
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
# HTTP Sensor Settings
"http_sensor_enabled": False,
"http_sensor_url": "",
"http_sensor_timeout": 10,
# Fan Control Settings
"enabled": False,
"poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10,
"max_speed": 100,
"panic_temp": 85,
"panic_speed": 100,
"panic_on_no_data": True,
"no_data_timeout": 60,
# Sensor Selection
"primary_sensor": "cpu", # cpu, cpu1, cpu2, inlet, exhaust, pcie, etc.
"sensor_preference": "auto", # ipmi, http, auto
# Fan Configuration
"fans": {}, # fan_id -> {"name": "Custom Name", "group": "group1"}
"fan_groups": {}, # group_name -> {"fans": ["0x00", "0x01"], "curve": "Default"}
# Fan Curves
"active_curve": "Balanced",
"fan_curves": {
"Balanced": {
"points": [
{"temp": 30, "speed": 10},
{"temp": 35, "speed": 12},
{"temp": 40, "speed": 15},
{"temp": 45, "speed": 20},
{"temp": 50, "speed": 30},
{"temp": 55, "speed": 40},
{"temp": 60, "speed": 55},
{"temp": 65, "speed": 70},
{"temp": 70, "speed": 85},
{"temp": 75, "speed": 95},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{"temp": 30, "speed": 5},
{"temp": 40, "speed": 10},
{"temp": 50, "speed": 15},
{"temp": 55, "speed": 25},
{"temp": 60, "speed": 35},
{"temp": 65, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 75, "speed": 85},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{"temp": 30, "speed": 20},
{"temp": 35, "speed": 25},
{"temp": 40, "speed": 35},
{"temp": 45, "speed": 45},
{"temp": 50, "speed": 55},
{"temp": 55, "speed": 70},
{"temp": 60, "speed": 85},
{"temp": 65, "speed": 95},
{"temp": 70, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
# UI Settings
"theme": "dark", # dark, light, auto
}
self._load_config()
self._last_data_time = datetime.utcnow()
def _load_config(self):
"""Load configuration from file."""
try:
config_file = Path(self.config_path)
if config_file.exists():
with open(config_file) as f:
loaded = json.load(f)
self._deep_update(self.config, loaded)
logger.info(f"Loaded config from {self.config_path}")
except Exception as e:
logger.error(f"Failed to load config: {e}")
def _deep_update(self, d: dict, u: dict):
"""Deep update dictionary."""
for k, v in u.items():
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
self._deep_update(d[k], v)
else:
d[k] = v
def _save_config(self):
"""Save configuration to file."""
try:
config_file = Path(self.config_path)
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w') as f:
json.dump(self.config, f, indent=2)
logger.info(f"Saved config to {self.config_path}")
except Exception as e:
logger.error(f"Failed to save config: {e}")
def update_config(self, **kwargs):
"""Update configuration values."""
self._deep_update(self.config, kwargs)
self._save_config()
# Reinitialize if needed
if any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']):
self._init_controller()
if any(k in kwargs for k in ['http_sensor_enabled', 'http_sensor_url']):
self._init_http_client()
def _init_controller(self) -> bool:
"""Initialize the IPMI controller."""
if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]):
return False
self.controller = IPMIFanController(
host=self.config['ipmi_host'],
username=self.config['ipmi_username'],
password=self.config.get('ipmi_password', ''),
port=self.config.get('ipmi_port', 623)
)
if self.controller.test_connection():
logger.info(f"Connected to IPMI at {self.config['ipmi_host']}")
return True
else:
logger.error(f"Failed to connect to IPMI")
self.controller = None
return False
def _init_http_client(self) -> bool:
"""Initialize HTTP sensor client."""
if not self.config.get('http_sensor_enabled'):
return False
url = self.config.get('http_sensor_url')
if not url:
return False
self.http_client = HTTPSensorClient(
url=url,
timeout=self.config.get('http_sensor_timeout', 10)
)
logger.info(f"HTTP sensor client initialized for {url}")
return True
def start(self) -> bool:
"""Start the controller service."""
if self.running:
return True
if not self._init_controller():
logger.error("Cannot start - IPMI connection failed")
return False
if self.config.get('http_sensor_enabled'):
self._init_http_client()
self.running = True
self.thread = threading.Thread(target=self._control_loop, daemon=True)
self.thread.start()
logger.info("IPMI Controller service started")
return True
def stop(self):
"""Stop the controller service."""
self.running = False
if self.thread:
self.thread.join(timeout=5)
if self.controller:
self.controller.disable_manual_fan_control()
logger.info("IPMI Controller service stopped")
def _control_loop(self):
"""Main control loop."""
if self.controller:
self.controller.enable_manual_fan_control()
poll_counter = 0
while self.running:
try:
if not self.config.get('enabled', False):
time.sleep(1)
continue
# Ensure controller is healthy
if not self.controller or not self.controller.is_healthy():
logger.warning("IPMI unhealthy, reconnecting...")
if not self._init_controller():
time.sleep(30)
continue
self.controller.enable_manual_fan_control()
# Poll temperatures at configured interval
poll_interval = self.config.get('poll_interval', 10)
if poll_counter % poll_interval == 0:
temps = self._get_temperatures()
fans = self.controller.get_fan_speeds() if self.controller else []
with self.lock:
self.last_temps = temps
self.last_fans = fans
if temps:
self._last_data_time = datetime.utcnow()
# Apply fan curves
if not self.in_identify_mode:
self._apply_fan_curves(temps)
poll_counter += 1
time.sleep(1)
except Exception as e:
logger.error(f"Control loop error: {e}")
time.sleep(10)
def _get_temperatures(self) -> List[TemperatureReading]:
"""Get temperatures from all sources."""
temps = []
preference = self.config.get('sensor_preference', 'ipmi')
# Try IPMI
if self.controller and preference in ['ipmi', 'auto']:
temps = self.controller.get_temperatures()
# Try HTTP sensor
if self.http_client and preference in ['http', 'auto']:
http_temps = self.http_client.fetch_sensors()
if http_temps:
if preference == 'http' or not temps:
temps = http_temps
else:
# Merge, preferring HTTP for PCIe sensors
temp_dict = {t.name: t for t in temps}
for ht in http_temps:
if ht.location == 'pcie' or ht.name not in temp_dict:
temps.append(ht)
return temps
def _apply_fan_curves(self, temps: List[TemperatureReading]):
"""Apply fan curves based on temperatures."""
if not temps:
# Check for panic mode on no data
if self.config.get('panic_on_no_data', True):
time_since_data = (datetime.utcnow() - self._last_data_time).total_seconds()
if time_since_data > self.config.get('no_data_timeout', 60):
self._set_all_fans(self.config.get('panic_speed', 100), "PANIC: No data")
return
# Get primary sensor
primary_sensor = self.config.get('primary_sensor', 'cpu')
sensor_temps = [t for t in temps if t.location == primary_sensor]
if not sensor_temps:
sensor_temps = [t for t in temps if t.location.startswith(primary_sensor)]
if not sensor_temps:
sensor_temps = temps # Fallback to any temp
max_temp = max(t.value for t in sensor_temps)
# Check panic temperature
if max_temp >= self.config.get('panic_temp', 85):
self._set_all_fans(self.config.get('panic_speed', 100), f"PANIC: Temp {max_temp}°C")
return
# Get fan curves
curves = self.config.get('fan_curves', {})
active_curve_name = self.config.get('active_curve', 'Balanced')
default_curve = curves.get(active_curve_name, curves.get('Balanced', {'points': [{'temp': 30, 'speed': 15}, {'temp': 80, 'speed': 100}]}))
# Apply curves to fans
fans = self.config.get('fans', {})
groups = self.config.get('fan_groups', {})
# Calculate target speeds per group/individual
fan_speeds = {}
for fan_id, fan_info in fans.items():
group = fan_info.get('group')
curve_name = fan_info.get('curve', 'Default')
if group and group in groups:
curve_name = groups[group].get('curve', 'Default')
curve = curves.get(curve_name, default_curve)
speed = self._calculate_curve_speed(max_temp, curve['points'])
# Apply limits
speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), speed))
fan_speeds[fan_id] = speed
# If no individual fan configs, apply to all
if not fan_speeds:
speed = self._calculate_curve_speed(max_temp, default_curve['points'])
speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), speed))
self._set_all_fans(speed, f"Temp {max_temp}°C")
else:
# Set individual fan speeds
for fan_id, speed in fan_speeds.items():
self._set_fan_speed(fan_id, speed, f"Temp {max_temp}°C")
def _calculate_curve_speed(self, temp: float, points: List[Dict]) -> int:
"""Calculate fan speed from curve points."""
if not points:
return 50
sorted_points = sorted(points, key=lambda p: p['temp'])
if temp <= sorted_points[0]['temp']:
return sorted_points[0]['speed']
if temp >= sorted_points[-1]['temp']:
return sorted_points[-1]['speed']
for i in range(len(sorted_points) - 1):
p1, p2 = sorted_points[i], sorted_points[i + 1]
if p1['temp'] <= temp <= p2['temp']:
if p2['temp'] == p1['temp']:
return p1['speed']
ratio = (temp - p1['temp']) / (p2['temp'] - p1['temp'])
speed = p1['speed'] + ratio * (p2['speed'] - p1['speed'])
return int(round(speed))
return sorted_points[-1]['speed']
def _set_all_fans(self, speed: int, reason: str):
"""Set all fans to a speed."""
if self.controller and speed != self.current_speeds.get('all'):
if self.controller.set_fan_speed(speed, "0xff"):
self.current_speeds['all'] = speed
logger.info(f"All fans set to {speed}% ({reason})")
def _set_fan_speed(self, fan_id: str, speed: int, reason: str):
"""Set specific fan speed."""
if self.controller and speed != self.current_speeds.get(fan_id):
if self.controller.set_fan_speed(speed, fan_id):
self.current_speeds[fan_id] = speed
logger.info(f"Fan {fan_id} set to {speed}% ({reason})")
def identify_fan(self, fan_id: str):
"""Identify a fan by setting it to 100% and others to 0%."""
if not self.controller:
return False
self.in_identify_mode = True
# Set all fans to 0%
self.controller.set_fan_speed(0, "0xff")
time.sleep(0.5)
# Set target fan to 100%
self.controller.set_fan_speed(100, fan_id)
return True
def stop_identify(self):
"""Stop identify mode and resume normal control."""
self.in_identify_mode = False
def set_manual_speed(self, speed: int, fan_id: str = "0xff") -> bool:
"""Set manual fan speed."""
if not self.controller:
return False
self.config['enabled'] = False
self._save_config()
speed = max(0, min(100, speed))
return self.controller.set_fan_speed(speed, fan_id)
def set_auto_mode(self, enabled: bool):
"""Enable or disable automatic control."""
self.config['enabled'] = enabled
self._save_config()
if enabled and self.controller:
self.controller.enable_manual_fan_control()
elif not enabled and self.controller:
self.controller.disable_manual_fan_control()
def get_status(self) -> Dict:
"""Get current controller status."""
with self.lock:
status = {
"running": self.running,
"enabled": self.config.get('enabled', False),
"connected": self.controller is not None and self.controller.is_healthy(),
"manual_mode": self.controller.manual_mode if self.controller else False,
"in_identify_mode": self.in_identify_mode,
"current_speeds": self.current_speeds,
"target_speeds": self.target_speeds,
"temperatures": [asdict(t) for t in self.last_temps],
"fans": [asdict(f) for f in self.last_fans],
"config": self._get_safe_config()
}
return status
def _get_safe_config(self) -> Dict:
"""Get config without sensitive data."""
safe = json.loads(json.dumps(self.config))
# Remove passwords
safe.pop('ipmi_password', None)
safe.pop('http_sensor_password', None)
return safe
# Global service instances
_service_instances: Dict[str, IPMIControllerService] = {}
def get_service(config_path: str = "/etc/ipmi-controller/config.json") -> IPMIControllerService:
"""Get or create the service instance."""
if config_path not in _service_instances:
_service_instances[config_path] = IPMIControllerService(config_path)
return _service_instances[config_path]
if __name__ == "__main__":
# CLI test
import sys
if len(sys.argv) < 4:
print("Usage: fan_controller.py <host> <username> <password>")
sys.exit(1)
host, user, pwd = sys.argv[1:4]
port = int(sys.argv[4]) if len(sys.argv) > 4 else 623
ctrl = IPMIFanController(host, user, pwd, port)
print(f"Testing {host}...")
if ctrl.test_connection():
print("✓ Connected")
print("\nTemps:", [(t.name, t.value) for t in ctrl.get_temperatures()])
print("\nFans:", [(f.fan_number, f.speed_rpm) for f in ctrl.get_fan_speeds()])
else:
print("✗ Failed")

13
frontend/index.html Normal file
View File

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

3327
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "ipmi-fan-control-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.5",
"@mui/material": "^5.15.5",
"@mui/x-charts": "^6.18.7",
"@tanstack/react-query": "^5.17.15",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.2",
"recharts": "^2.10.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Box, CircularProgress } from '@mui/material';
import { useAuthStore } from './stores/authStore';
import { setupApi } from './utils/api';
// Pages
import SetupWizard from './pages/SetupWizard';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ServerList from './pages/ServerList';
import ServerDetail from './pages/ServerDetail';
import FanCurves from './pages/FanCurves';
import Logs from './pages/Logs';
import Layout from './components/Layout';
function App() {
const { isAuthenticated, fetchUser, token } = useAuthStore();
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const location = useLocation();
// Check if setup is complete
const { data: setupStatus, isLoading: isSetupLoading } = useQuery({
queryKey: ['setup-status'],
queryFn: async () => {
const response = await setupApi.getStatus();
return response.data;
},
});
// Check auth status on mount
useEffect(() => {
const checkAuth = async () => {
if (token) {
await fetchUser();
}
setIsCheckingAuth(false);
};
checkAuth();
}, [token, fetchUser]);
if (isSetupLoading || isCheckingAuth) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
// If setup is not complete, show setup wizard
if (!setupStatus?.setup_complete) {
return (
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
);
}
// If not authenticated, show login
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace state={{ from: location }} />} />
</Routes>
);
}
// Authenticated routes
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/servers" element={<ServerList />} />
<Route path="/servers/:id" element={<ServerDetail />} />
<Route path="/servers/:id/curves" element={<FanCurves />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
export default App;

View File

@ -0,0 +1,541 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Paper,
Grid,
Button,
List,
ListItem,
ListItemText,
ListItemButton,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Card,
CardContent,
Tooltip,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
PlayArrow as PlayIcon,
Stop as StopIcon,
ShowChart as ChartIcon,
} from '@mui/icons-material';
import { fanCurvesApi, fanControlApi } from '../utils/api';
import type { FanCurve, FanCurvePoint, Server } from '../types';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, ResponsiveContainer } from 'recharts';
interface FanCurveManagerProps {
serverId: number;
server: Server;
}
export default function FanCurveManager({ serverId, server }: FanCurveManagerProps) {
const queryClient = useQueryClient();
const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null);
const [openDialog, setOpenDialog] = useState(false);
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null);
const [error, setError] = useState('');
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: curves, isLoading } = 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();
},
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to create fan curve');
},
});
const updateMutation = useMutation({
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
fanCurvesApi.update(serverId, curveId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
handleCloseDialog();
},
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to update fan curve');
},
});
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) => {
setError('');
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);
setError('');
};
const handleSubmit = () => {
setError('');
if (!formData.name.trim()) {
setError('Curve name is required');
return;
}
if (formData.points.length < 2) {
setError('At least 2 points are required');
return;
}
for (const point of formData.points) {
if (point.speed < 0 || point.speed > 100) {
setError('Fan speed must be between 0 and 100');
return;
}
if (point.temp < 0 || point.temp > 150) {
setError('Temperature must be between 0 and 150');
return;
}
}
const data = {
name: formData.name.trim(),
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),
});
}
};
const isActiveCurve = (curve: FanCurve) => {
return server.auto_control_enabled && selectedCurve?.id === curve.id;
};
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
<ChartIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Fan Curves
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{server.auto_control_enabled ? (
<Button
variant="outlined"
color="error"
size="small"
startIcon={<StopIcon />}
onClick={() => disableAutoMutation.mutate()}
>
Stop Auto
</Button>
) : (
<Button
variant="outlined"
size="small"
startIcon={<PlayIcon />}
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
disabled={!selectedCurve}
>
Start Auto
</Button>
)}
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
New Curve
</Button>
</Box>
</Box>
{server.auto_control_enabled && (
<Alert severity="success" sx={{ mb: 2 }}>
Automatic fan control is active
{selectedCurve && ` - Using "${selectedCurve.name}"`}
</Alert>
)}
<Grid container spacing={2}>
{/* Curve List */}
<Grid item xs={12} md={5}>
<Paper variant="outlined">
<List dense>
{isLoading ? (
<ListItem>
<ListItemText primary="Loading..." />
</ListItem>
) : curves?.length === 0 ? (
<ListItem>
<ListItemText
primary="No fan curves"
secondary="Create a curve to enable automatic fan control"
/>
</ListItem>
) : (
curves?.map((curve) => (
<ListItem
key={curve.id}
secondaryAction={
<Box>
<Tooltip title="Edit">
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenDialog(curve);
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this fan curve?')) {
deleteMutation.mutate(curve.id);
}
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
}
disablePadding
>
<ListItemButton
selected={selectedCurve?.id === curve.id}
onClick={() => setSelectedCurve(curve)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{curve.name}
{isActiveCurve(curve) && (
<Chip size="small" color="success" label="Active" />
)}
</Box>
}
secondary={
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Chip size="small" label={curve.sensor_source} variant="outlined" />
<Chip size="small" label={`${curve.curve_data.length} points`} variant="outlined" />
</Box>
}
/>
</ListItemButton>
</ListItem>
))
)}
</List>
</Paper>
</Grid>
{/* Curve Preview */}
<Grid item xs={12} md={7}>
<Paper variant="outlined" sx={{ p: 2, height: 280 }}>
{selectedCurve ? (
<>
<Typography variant="subtitle1" gutterBottom>
{selectedCurve.name}
</Typography>
<ResponsiveContainer width="100%" height="85%">
<LineChart
data={selectedCurve.curve_data.map((p) => ({
...p,
label: `${p.temp}°C`,
}))}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="temp"
label={{ value: 'Temp (°C)', position: 'insideBottom', offset: -5 }}
type="number"
domain={[0, 'dataMax + 10']}
/>
<YAxis
label={{ value: 'Fan %', angle: -90, position: 'insideLeft' }}
domain={[0, 100]}
/>
<ChartTooltip
formatter={(value: number, name: string) => [
name === 'speed' ? `${value}%` : `${value}°C`,
name === 'speed' ? 'Fan Speed' : 'Temperature',
]}
/>
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 5 }}
activeDot={{ r: 7 }}
/>
</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>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="Curve Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
required
error={!formData.name && !!error}
/>
<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 }}
inputProps={{ min: 0, max: 150 }}
/>
<TextField
type="number"
label="Fan Speed (%)"
value={point.speed}
onChange={(e) =>
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
}
sx={{ flex: 1 }}
inputProps={{ min: 0, max: 100 }}
/>
<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 }}
startIcon={<AddIcon />}
>
Add Point
</Button>
{/* Preview Chart */}
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Preview
</Typography>
<Paper variant="outlined" sx={{ p: 2, height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={formData.points}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="temp" type="number" domain={[0, 'dataMax + 10']} />
<YAxis domain={[0, 100]} />
<ChartTooltip
formatter={(value: number, name: string) => [
name === 'speed' ? `${value}%` : `${value}°C`,
name === 'speed' ? 'Fan Speed' : 'Temperature',
]}
/>
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
>
{editingCurve ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,191 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
IconButton,
Divider,
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>
);
}

View File

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

@ -0,0 +1,48 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,443 @@
import React from 'react';
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,
Skeleton,
} from '@mui/material';
import {
Dns as ServerIcon,
Speed as SpeedIcon,
Warning as WarningIcon,
Error as ErrorIcon,
CheckCircle as CheckIcon,
Thermostat as TempIcon,
Refresh as RefreshIcon,
PowerSettingsNew as PowerIcon,
Memory as MemoryIcon,
} from '@mui/icons-material';
import { dashboardApi } from '../utils/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface ServerOverview {
id: number;
name: string;
vendor: string;
is_active: boolean;
manual_control_enabled: boolean;
auto_control_enabled: boolean;
max_temp: number | null;
avg_fan_speed: number | null;
power_consumption: number | null;
last_updated: string | null;
cached: boolean;
}
export default function Dashboard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Stats query - poll every 60 seconds (stats don't change often)
const { data: stats } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: async () => {
const response = await dashboardApi.getStats();
return response.data;
},
refetchInterval: 60000, // 60 seconds
staleTime: 55000,
});
// Server overview query - poll every 30 seconds (matches sensor collector)
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['servers-overview'],
queryFn: async () => {
const response = await dashboardApi.getServersOverview();
return response.data.servers as ServerOverview[];
},
refetchInterval: 30000, // 30 seconds - matches sensor collector
staleTime: 25000,
// Don't refetch on window focus to reduce load
refetchOnWindowFocus: false,
});
// Background refresh mutation
const refreshMutation = useMutation({
mutationFn: async (serverId: number) => {
const response = await dashboardApi.refreshServer(serverId);
return response.data;
},
onSuccess: () => {
// Invalidate overview after a short delay to allow background fetch
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['servers-overview'] });
}, 2000);
},
});
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>
);
const ServerCard = ({ server }: { server: ServerOverview }) => {
const hasData = server.max_temp !== null || server.avg_fan_speed !== null;
const isLoading = !hasData && server.is_active;
const getTempColor = (temp: number | null) => {
if (temp === null) return 'text.secondary';
if (temp > 80) return 'error.main';
if (temp > 70) return 'warning.main';
return 'success.main';
};
const getStatusChip = () => {
if (!server.is_active) {
return <Chip size="small" label="Offline" color="default" icon={<PowerIcon />} />;
}
if (server.manual_control_enabled) {
return <Chip size="small" label="Manual" color="info" icon={<SpeedIcon />} />;
}
if (server.auto_control_enabled) {
return <Chip size="small" label="Auto" color="success" icon={<CheckIcon />} />;
}
return <Chip size="small" label="Active" color="success" />;
};
return (
<Card
variant="outlined"
sx={{
cursor: 'pointer',
transition: 'all 0.2s',
opacity: isLoading ? 0.7 : 1,
'&:hover': {
boxShadow: 2,
borderColor: 'primary.main',
},
}}
onClick={() => navigate(`/servers/${server.id}`)}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ServerIcon color={server.is_active ? 'primary' : 'disabled'} />
<Typography variant="subtitle1" fontWeight="medium" noWrap sx={{ maxWidth: 150 }}>
{server.name}
</Typography>
</Box>
{getStatusChip()}
</Box>
{/* Metrics Grid - Always show values or -- placeholder */}
<Grid container spacing={1} sx={{ mb: 1 }}>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color={getTempColor(server.max_temp)}>
{server.max_temp !== null ? `${Math.round(server.max_temp)}°C` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Max Temp
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color="primary.main">
{server.avg_fan_speed !== null ? `${Math.round(server.avg_fan_speed)}%` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Avg Fan
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color="text.primary">
{server.power_consumption !== null ? `${Math.round(server.power_consumption)}W` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Power
</Typography>
</Box>
</Grid>
</Grid>
{/* Footer */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<Typography variant="caption" color="text.secondary">
{server.vendor || 'Unknown Vendor'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isLoading ? (
<Chip size="small" label="Loading..." color="warning" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
) : server.cached ? (
<Chip size="small" label="Cached" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
) : null}
<Tooltip title="Refresh data">
<IconButton
size="small"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
refreshMutation.mutate(server.id);
}}
disabled={refreshMutation.isPending}
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</CardContent>
</Card>
);
};
// Show placeholder cards while loading initial data
const ServersPlaceholderGrid = () => (
<Grid container spacing={2}>
{[1, 2, 3, 4].map((i) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={i}>
<Card variant="outlined" sx={{ opacity: 0.5 }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" width="60%" />
</Box>
<Grid container spacing={1}>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
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>
{/* Servers Grid */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">
Server Overview
</Typography>
<Chip
label={`${overviewData?.length || 0} servers`}
size="small"
color="primary"
variant="outlined"
/>
</Box>
{overviewLoading ? (
<ServersPlaceholderGrid />
) : overviewData && overviewData.length > 0 ? (
<Grid container spacing={2}>
{overviewData.map((server) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={server.id}>
<ServerCard server={server} />
</Grid>
))}
</Grid>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<ServerIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
No servers configured
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Add your first server to start monitoring
</Typography>
<Chip
label="Add Server"
color="primary"
onClick={() => navigate('/servers')}
clickable
/>
</Box>
)}
</Paper>
{/* 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: any) => (
<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>
About IPMI Fan Control
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This application allows you to control fan speeds on Dell T710 and compatible servers
using IPMI commands. Features include:
</Typography>
<List dense>
<ListItem>
<ListItemIcon><SpeedIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Manual fan control with per-fan adjustment" />
</ListItem>
<ListItem>
<ListItemIcon><TempIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Automatic fan curves based on temperature sensors" />
</ListItem>
<ListItem>
<ListItemIcon><MemoryIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="SSH-based CPU temperature monitoring" />
</ListItem>
<ListItem>
<ListItemIcon><ErrorIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Safety panic mode for overheating protection" />
</ListItem>
</List>
</Paper>
</Grid>
</Grid>
</Box>
);
}

View File

@ -0,0 +1,518 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Paper,
Grid,
Button,
List,
ListItem,
ListItemText,
ListItemButton,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Divider,
CircularProgress,
} 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 } 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 [error, setError] = useState('');
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();
},
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to create fan curve');
},
});
const updateMutation = useMutation({
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
fanCurvesApi.update(serverId, curveId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
handleCloseDialog();
},
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to update fan curve');
},
});
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) => {
setError('');
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);
setError('');
};
const handleSubmit = () => {
setError('');
// Validation
if (!formData.name.trim()) {
setError('Curve name is required');
return;
}
if (formData.points.length < 2) {
setError('At least 2 points are required');
return;
}
// Validate points
for (const point of formData.points) {
if (point.speed < 0 || point.speed > 100) {
setError('Fan speed must be between 0 and 100');
return;
}
if (point.temp < 0 || point.temp > 150) {
setError('Temperature must be between 0 and 150');
return;
}
}
const data = {
name: formData.name.trim(),
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),
});
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
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',
]}
/>
<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>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="Curve Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
required
error={!formData.name && !!error}
helperText={!formData.name && error ? 'Name is required' : 'Enter a name for this fan curve'}
/>
<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 }}
inputProps={{ min: 0, max: 150 }}
/>
<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} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={isLoading || !formData.name.trim()}
startIcon={isLoading ? <CircularProgress size={20} /> : null}
>
{isLoading ? 'Saving...' : editingCurve ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -0,0 +1,105 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Alert,
CircularProgress,
} from '@mui/material';
import { useAuthStore } from '../stores/authStore';
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const { login, error, clearError, isLoading } = useAuthStore();
const [formData, setFormData] = useState({
username: '',
password: '',
});
const from = (location.state as any)?.from?.pathname || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
await login(formData.username, formData.password);
navigate(from, { replace: true });
} catch {
// Error is handled by the store
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'background.default',
}}
>
<Card sx={{ maxWidth: 400, width: '100%' }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom align="center">
IPMI Fan Control
</Typography>
<Typography variant="body2" color="text.secondary" align="center" paragraph>
Sign in to manage your servers
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
margin="normal"
required
disabled={isLoading}
autoFocus
/>
<TextField
fullWidth
type="password"
label="Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
margin="normal"
required
disabled={isLoading}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
sx={{ mt: 3 }}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={20} /> : null}
>
Sign In
</Button>
</form>
</CardContent>
</Card>
</Box>
);
}

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

@ -0,0 +1,197 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
FormControl,
InputLabel,
Select,
MenuItem,
Pagination,
} from '@mui/material';
import {
Error as ErrorIcon,
Warning as WarningIcon,
CheckCircle as CheckIcon,
Info as InfoIcon,
Speed as SpeedIcon,
} from '@mui/icons-material';
import { logsApi, serversApi } from '../utils/api';
const LOGS_PER_PAGE = 25;
export default function Logs() {
const [page, setPage] = useState(1);
const [eventType, setEventType] = useState<string>('');
const [serverFilter, setServerFilter] = useState<number | ''>('');
const { data: servers } = useQuery({
queryKey: ['servers'],
queryFn: async () => {
const response = await serversApi.getAll();
return response.data;
},
});
const { data: logs } = useQuery({
queryKey: ['logs', eventType, serverFilter],
queryFn: async () => {
const response = await logsApi.getAll({
event_type: eventType || undefined,
server_id: serverFilter || undefined,
limit: 100,
});
return response.data;
},
});
const getEventIcon = (eventType: string) => {
switch (eventType) {
case 'panic':
return <ErrorIcon color="error" />;
case 'error':
return <ErrorIcon color="error" />;
case 'warning':
return <WarningIcon color="warning" />;
case 'fan_change':
return <SpeedIcon color="info" />;
case 'info':
return <InfoIcon color="info" />;
default:
return <CheckIcon color="success" />;
}
};
const getEventColor = (eventType: string) => {
switch (eventType) {
case 'panic':
return 'error';
case 'error':
return 'error';
case 'warning':
return 'warning';
case 'fan_change':
return 'info';
case 'info':
return 'default';
default:
return 'default';
}
};
const totalPages = Math.ceil((logs?.length || 0) / LOGS_PER_PAGE);
const paginatedLogs = logs?.slice((page - 1) * LOGS_PER_PAGE, page * LOGS_PER_PAGE);
return (
<Box>
<Typography variant="h4" gutterBottom>
System Logs
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>Event Type</InputLabel>
<Select
value={eventType}
label="Event Type"
onChange={(e) => setEventType(e.target.value)}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="panic">Panic</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="warning">Warning</MenuItem>
<MenuItem value="fan_change">Fan Change</MenuItem>
<MenuItem value="info">Info</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Server</InputLabel>
<Select
value={serverFilter}
label="Server"
onChange={(e) => setServerFilter(e.target.value as number | '')}
>
<MenuItem value="">All Servers</MenuItem>
{servers?.map((server) => (
<MenuItem key={server.id} value={server.id}>
{server.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell width={50}></TableCell>
<TableCell>Time</TableCell>
<TableCell>Server</TableCell>
<TableCell>Type</TableCell>
<TableCell>Message</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedLogs?.map((log) => (
<TableRow key={log.id} hover>
<TableCell>{getEventIcon(log.event_type)}</TableCell>
<TableCell>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>
{log.server_id
? servers?.find((s) => s.id === log.server_id)?.name ||
`Server ${log.server_id}`
: 'System'}
</TableCell>
<TableCell>
<Chip
size="small"
label={log.event_type}
color={getEventColor(log.event_type) as any}
/>
</TableCell>
<TableCell>{log.message}</TableCell>
<TableCell>{log.details}</TableCell>
</TableRow>
))}
{!paginatedLogs?.length && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
No logs found
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</Box>
);
}

View File

@ -0,0 +1,745 @@
import { useState } 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,
Tooltip,
IconButton,
} 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 FanCurveManager from '../components/FanCurveManager';
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;
},
// Server config rarely changes
refetchInterval: 60000,
staleTime: 55000,
refetchOnWindowFocus: false,
});
const { data: sensors, refetch: refetchSensors } = useQuery({
queryKey: ['sensors', serverId],
queryFn: async () => {
const response = await serversApi.getSensors(serverId);
return response.data;
},
refetchInterval: 30000, // 30 seconds - matches sensor collector
staleTime: 25000,
refetchOnWindowFocus: false,
});
// Get SSH sensors for core temps - use dedicated endpoint
const { data: sshSensors, isLoading: isSSHSensorsLoading } = useQuery({
queryKey: ['ssh-sensors', serverId],
queryFn: async () => {
if (!server?.use_ssh) return null;
try {
const response = await serversApi.getSSHSensors(serverId);
return response.data;
} catch {
return null;
}
},
enabled: !!server?.use_ssh,
refetchInterval: 30000, // SSH is slow - refresh less frequently
staleTime: 25000,
refetchOnWindowFocus: false,
});
const { data: dashboardData } = useQuery({
queryKey: ['dashboard-server', serverId],
queryFn: async () => {
const response = await dashboardApi.getServerData(serverId);
return response.data;
},
refetchInterval: 60000, // Historical data doesn't change often
staleTime: 55000,
refetchOnWindowFocus: false,
});
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')) || [];
// Format SSH CPU temps for display
const sshCPUTemps = sshSensors?.cpu_temps || [];
// Get detected fans from IPMI sensors, sorted by fan number
const detectedFans = sensors?.fans
? [...sensors.fans].sort((a, b) => a.fan_number - b.fan_number)
: [];
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">
IPMI: {server.ipmi_host}:{server.ipmi_port} {server.vendor.toUpperCase()}
{server.use_ssh && ` • SSH: ${server.ssh_host || server.ipmi_host}`}
</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" />
)}
{server.use_ssh && (
<Chip color="info" label="SSH Enabled" />
)}
</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}>
{/* CPU Temps from SSH (preferred) or IPMI */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
CPU Core Temperatures
</Typography>
{isSSHSensorsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<CircularProgress size={24} />
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
Loading SSH sensors...
</Typography>
</Box>
) : sshCPUTemps.length > 0 ? (
<>
{sshCPUTemps.map((cpu: any, idx: number) => (
<Box key={idx} sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{cpu.cpu_name || `CPU ${idx + 1}`}
{cpu.package_temp && ` (Package: ${cpu.package_temp}°C)`}
</Typography>
<Grid container spacing={1}>
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
<Grid item xs={6} sm={4} key={coreName}>
<Paper
variant="outlined"
sx={{ p: 1.5, textAlign: 'center' }}
>
<Typography variant="h6" color="success">
{temp as number}°C
</Typography>
<Typography variant="body2" color="text.secondary">
{coreName}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Box>
))}
</>
) : cpuTemps.length > 0 ? (
<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>
))}
</Grid>
) : (
<Alert severity="info">
No CPU temperature data available. Enable SSH for detailed core temperatures.
</Alert>
)}
</CardContent>
</Card>
</Grid>
{/* Fan Speeds */}
<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>
{/* Power Consumption */}
{dashboardData?.power_consumption && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Power Consumption
</Typography>
{/* Handle numeric power value (from cache) */}
{typeof dashboardData.power_consumption === 'number' && (
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center', maxWidth: 300 }}>
<Typography variant="body2" color="text.secondary">
Current Power Consumption
</Typography>
<Typography variant="h3" color="primary.main" sx={{ mt: 1 }}>
{Math.round(dashboardData.power_consumption)}W
</Typography>
</Paper>
)}
{/* Handle dictionary power data (from live IPMI) */}
{typeof dashboardData.power_consumption === 'object' && (
<Grid container spacing={2}>
{Object.entries(dashboardData.power_consumption)
.filter(([_, value]) => {
// Filter out empty values, timestamps, and metadata
if (!value || value === '') return false;
if (typeof value === 'string' && value.includes('UTC')) return false;
return true;
})
.map(([key, value]) => {
// Show the raw value as-is from IPMI
const displayValue = typeof value === 'string' ? value : String(value);
return (
<Grid item xs={6} md={3} key={key}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{key}
</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>
{displayValue}
</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>
Manual Fan Control
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
Enable manual control to set fan speeds. When disabled, the server uses automatic fan control.
</Alert>
<Box sx={{ mb: 3 }}>
<FormControlLabel
control={
<Switch
checked={server.manual_control_enabled}
onChange={(e) => {
if (e.target.checked) {
enableManualMutation.mutate();
} else {
disableManualMutation.mutate();
}
}}
/>
}
label="Enable Manual Fan Control"
/>
</Box>
{server.manual_control_enabled && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Set Fan Speed
</Typography>
{detectedFans.length === 0 && (
<Alert severity="warning" sx={{ mb: 2 }}>
No fans detected via IPMI. Please check your server connection.
</Alert>
)}
<Box sx={{ mb: 2 }}>
<Typography gutterBottom>
Target: {selectedFan === '0xff' ? 'All Fans' :
detectedFans.find(f => f.fan_id === selectedFan)
? `Fan ${detectedFans.find(f => f.fan_id === selectedFan)?.fan_number}`
: 'Unknown Fan'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<Button
variant={selectedFan === '0xff' ? 'contained' : 'outlined'}
size="small"
onClick={() => setSelectedFan('0xff')}
disabled={detectedFans.length === 0}
>
All Fans
</Button>
{detectedFans.map((fan) => (
<Button
key={fan.fan_id}
variant={selectedFan === fan.fan_id ? 'contained' : 'outlined'}
size="small"
onClick={() => setSelectedFan(fan.fan_id)}
title={`Fan ${fan.fan_number} - ${fan.speed_rpm ? fan.speed_rpm + ' RPM' : 'No RPM data'}`}
>
Fan {fan.fan_number}
</Button>
))}
</Box>
</Box>
<Box sx={{ px: 2, py: 2 }}>
<Typography gutterBottom color={detectedFans.length === 0 ? 'text.secondary' : 'inherit'}>
Speed: {fanSpeed}%
</Typography>
<Slider
value={fanSpeed}
onChange={handleFanSpeedChange}
min={10}
max={100}
step={1}
marks={[
{ value: 10, label: '10%' },
{ value: 50, label: '50%' },
{ value: 100, label: '100%' },
]}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `${v}%`}
disabled={detectedFans.length === 0}
/>
</Box>
<Button
variant="contained"
fullWidth
sx={{ mt: 2 }}
onClick={handleApplyFanSpeed}
disabled={setFanSpeedMutation.isPending || detectedFans.length === 0}
startIcon={<SpeedIcon />}
>
Apply {fanSpeed}% Speed
</Button>
</>
)}
</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)"
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Timeout: {server.panic_timeout_seconds} seconds
</Typography>
</Box>
<Divider sx={{ my: 2 }} />
<Box>
<FormControlLabel
control={<Switch checked={server.third_party_pcie_response} disabled />}
label="3rd Party PCIe Card Response"
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Controls fan response when using non-Dell PCIe cards
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
{/* Fan Curves Section */}
<Grid item xs={12}>
<FanCurveManager serverId={serverId} server={server} />
</Grid>
</Grid>
</TabPanel>
{/* Sensors Tab - Merged IPMI and SSH */}
<TabPanel value={tabValue} index={2}>
<Grid container spacing={3}>
{/* SSH Core Temps */}
{isSSHSensorsLoading ? (
<Grid item xs={12}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<CircularProgress size={24} sx={{ mr: 1 }} />
<Typography color="text.secondary">Loading SSH sensors...</Typography>
</Box>
</CardContent>
</Card>
</Grid>
) : sshCPUTemps.length > 0 && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
CPU Core Temperatures (lm-sensors via SSH)
</Typography>
<Grid container spacing={3}>
{sshCPUTemps.map((cpu: any, idx: number) => (
<Grid item xs={12} md={6} key={idx}>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
{cpu.cpu_name || `CPU ${idx + 1}`}
{cpu.package_temp && (
<Chip
size="small"
label={`Package: ${cpu.package_temp}°C`}
color="primary"
sx={{ ml: 1 }}
/>
)}
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Core</TableCell>
<TableCell align="right">Temp</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
<TableRow key={coreName}>
<TableCell>{coreName}</TableCell>
<TableCell align="right">
<Chip
label={`${temp as number}°C`}
color="success"
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
))}
</Grid>
</CardContent>
</Card>
</Grid>
)}
{/* Raw SSH Sensors Data (if available) */}
{sshSensors?.raw_data && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
Raw lm-sensors Data
</Typography>
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
<pre style={{ margin: 0, fontSize: '0.875rem' }}>
{JSON.stringify(sshSensors.raw_data, null, 2)}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* IPMI Temperatures */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">IPMI Temperature Sensors</Typography>
<Tooltip title="Refresh">
<IconButton onClick={() => refetchSensors()}>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
<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>
{/* IPMI All Sensors */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
All IPMI Sensors
</Typography>
<TableContainer sx={{ maxHeight: 400 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Sensor</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sensors?.all_sensors.slice(0, 50).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)
.filter(([_key, value]) => {
// Filter out entries that look like timestamps or are too messy
const valueStr = String(value);
return !valueStr.includes('UTC') &&
!valueStr.includes('Peak Time') &&
!valueStr.includes('Statistic') &&
valueStr.length < 50;
})
.map(([key, value]) => {
let displayValue = String(value);
let displayKey = key;
// Clean up Dell power monitor output
if (value.includes('Reading')) {
const match = value.match(/Reading\s*:\s*([\d.]+)\s*(\w+)/);
if (match) {
displayValue = `${match[1]} ${match[2]}`;
}
}
if (value.includes('Statistic')) {
const match = value.match(/Statistic\s*:\s*(.+)/);
if (match) {
displayValue = match[1];
}
}
return (
<Grid item xs={12} md={4} key={key}>
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
{displayKey.replace(/_/g, ' ')}
</Typography>
<Typography variant="h5" sx={{ mt: 1 }}>
{displayValue}
</Typography>
</Paper>
</Grid>
);
})}
</Grid>
) : (
<Alert severity="info">
Power consumption data is not available for this server.
</Alert>
)}
</CardContent>
</Card>
</TabPanel>
</Box>
);
}

View File

@ -0,0 +1,622 @@
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,
Divider,
Switch,
FormControlLabel,
Stepper,
Step,
StepLabel,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Speed as SpeedIcon,
Computer as ComputerIcon,
} from '@mui/icons-material';
import { serversApi } from '../utils/api';
import type { Server } from '../types';
const STEPS = ['IPMI Connection', 'SSH Connection (Optional)', 'Review'];
export default function ServerList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [openDialog, setOpenDialog] = useState(false);
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [activeStep, setActiveStep] = useState(0);
const [formData, setFormData] = useState({
// Basic
name: '',
vendor: 'dell',
// IPMI
ipmi_host: '',
ipmi_port: 623,
ipmi_username: '',
ipmi_password: '',
// SSH
use_ssh: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
});
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,
vendor: server.vendor,
ipmi_host: server.ipmi_host,
ipmi_port: server.ipmi_port,
ipmi_username: server.ipmi_username,
ipmi_password: '',
use_ssh: server.use_ssh,
ssh_host: server.ssh_host || '',
ssh_port: server.ssh_port,
ssh_username: server.ssh_username || '',
ssh_password: '',
});
} else {
setEditingServer(null);
setActiveStep(0);
setFormData({
name: '',
vendor: 'dell',
ipmi_host: '',
ipmi_port: 623,
ipmi_username: '',
ipmi_password: '',
use_ssh: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
});
}
setFormError('');
setOpenDialog(true);
};
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingServer(null);
setActiveStep(0);
setFormError('');
};
const validateStep = (step: number): boolean => {
if (step === 0) {
// Validate IPMI fields
if (!formData.name.trim()) {
setFormError('Server name is required');
return false;
}
if (!formData.ipmi_host.trim()) {
setFormError('IPMI IP address/hostname is required');
return false;
}
if (!formData.ipmi_username.trim()) {
setFormError('IPMI username is required');
return false;
}
if (!editingServer && !formData.ipmi_password) {
setFormError('IPMI password is required');
return false;
}
}
setFormError('');
return true;
};
const handleNext = () => {
if (validateStep(activeStep)) {
setActiveStep((prev) => prev + 1);
}
};
const handleBack = () => {
setFormError('');
setActiveStep((prev) => prev - 1);
};
const handleSubmit = () => {
const data = {
name: formData.name,
vendor: formData.vendor,
ipmi: {
ipmi_host: formData.ipmi_host,
ipmi_port: formData.ipmi_port,
ipmi_username: formData.ipmi_username,
ipmi_password: formData.ipmi_password,
},
ssh: {
use_ssh: formData.use_ssh,
ssh_host: formData.ssh_host || formData.ipmi_host,
ssh_port: formData.ssh_port,
ssh_username: formData.ssh_username || formData.ipmi_username,
ssh_password: formData.ssh_password,
},
};
if (editingServer) {
const updateData: any = {
name: data.name,
vendor: data.vendor,
ipmi_host: data.ipmi.ipmi_host,
ipmi_port: data.ipmi.ipmi_port,
ipmi_username: data.ipmi.ipmi_username,
use_ssh: data.ssh.use_ssh,
ssh_host: data.ssh.ssh_host,
ssh_port: data.ssh.ssh_port,
ssh_username: data.ssh.ssh_username,
};
if (data.ipmi.ipmi_password) {
updateData.ipmi_password = data.ipmi.ipmi_password;
}
if (data.ssh.ssh_password) {
updateData.ssh_password = data.ssh.ssh_password;
}
updateMutation.mutate({ id: editingServer.id, data: updateData });
} else {
createMutation.mutate(data);
}
};
const renderStepContent = (step: number) => {
switch (step) {
case 0:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom color="primary">
<ComputerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
IPMI Connection (Required)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Enter the IPMI/iDRAC/iLO credentials for fan control and basic sensor reading.
</Typography>
<TextField
fullWidth
label="Server Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
required
helperText="A friendly name for this server"
/>
<FormControl fullWidth margin="normal">
<InputLabel>Server Vendor</InputLabel>
<Select
value={formData.vendor}
label="Server Vendor"
onChange={(e) => setFormData({ ...formData, vendor: e.target.value })}
>
<MenuItem value="dell">Dell (iDRAC)</MenuItem>
<MenuItem value="hpe">HPE (iLO)</MenuItem>
<MenuItem value="supermicro">Supermicro</MenuItem>
<MenuItem value="other">Other</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="IPMI IP Address / Hostname"
value={formData.ipmi_host}
onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })}
margin="normal"
required
placeholder="192.168.1.100"
helperText="The IP address of your iDRAC/iLO/IPMI interface"
/>
<TextField
fullWidth
type="number"
label="IPMI Port"
value={formData.ipmi_port}
onChange={(e) => setFormData({ ...formData, ipmi_port: parseInt(e.target.value) || 623 })}
margin="normal"
helperText="Default is 623 for most servers"
/>
<TextField
fullWidth
label="IPMI Username"
value={formData.ipmi_username}
onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })}
margin="normal"
required
placeholder="root"
/>
<TextField
fullWidth
type="password"
label={editingServer ? 'IPMI Password (leave blank to keep current)' : 'IPMI Password'}
value={formData.ipmi_password}
onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })}
margin="normal"
required={!editingServer}
/>
</Box>
);
case 1:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom color="primary">
SSH Connection (Optional)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
SSH provides more detailed sensor data via lm-sensors (CPU core temperatures, etc.).
If not configured, only IPMI sensors will be used.
</Typography>
<FormControlLabel
control={
<Switch
checked={formData.use_ssh}
onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })}
/>
}
label="Enable SSH for detailed sensor data"
/>
{formData.use_ssh && (
<>
<Divider sx={{ my: 2 }} />
<TextField
fullWidth
label="SSH Host"
value={formData.ssh_host}
onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })}
margin="normal"
placeholder={formData.ipmi_host}
helperText="Leave empty to use IPMI host"
/>
<TextField
fullWidth
type="number"
label="SSH Port"
value={formData.ssh_port}
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
margin="normal"
/>
<TextField
fullWidth
label="SSH Username"
value={formData.ssh_username}
onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })}
margin="normal"
placeholder={formData.ipmi_username}
helperText="Leave empty to use IPMI username"
/>
<TextField
fullWidth
type="password"
label={editingServer ? 'SSH Password (leave blank to keep current)' : 'SSH Password'}
value={formData.ssh_password}
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
margin="normal"
helperText="Password for SSH authentication"
/>
</>
)}
</Box>
);
case 2:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Review Configuration
</Typography>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
Basic Info
</Typography>
<Typography><strong>Name:</strong> {formData.name}</Typography>
<Typography><strong>Vendor:</strong> {formData.vendor.toUpperCase()}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
IPMI Connection
</Typography>
<Typography><strong>Host:</strong> {formData.ipmi_host}:{formData.ipmi_port}</Typography>
<Typography><strong>Username:</strong> {formData.ipmi_username}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
SSH Connection
</Typography>
<Typography>
<strong>Status:</strong> {formData.use_ssh ? 'Enabled' : 'Disabled'}
</Typography>
{formData.use_ssh && (
<>
<Typography><strong>Host:</strong> {formData.ssh_host || formData.ipmi_host}:{formData.ssh_port}</Typography>
<Typography><strong>Username:</strong> {formData.ssh_username || formData.ipmi_username}</Typography>
</>
)}
</Paper>
</Box>
);
default:
return null;
}
};
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>IPMI Host</TableCell>
<TableCell>SSH</TableCell>
<TableCell>Status</TableCell>
<TableCell>Last Seen</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servers?.map((server) => (
<TableRow
key={server.id}
hover
onClick={() => navigate(`/servers/${server.id}`)}
sx={{ cursor: 'pointer' }}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{server.name}
<Chip size="small" label={server.vendor.toUpperCase()} variant="outlined" />
</Box>
</TableCell>
<TableCell>{server.ipmi_host}</TableCell>
<TableCell>
{server.use_ssh ? (
<Chip size="small" color="success" label="Enabled" />
) : (
<Chip size="small" color="default" label="Disabled" />
)}
</TableCell>
<TableCell>{getStatusChip(server)}</TableCell>
<TableCell>
{server.last_seen
? new Date(server.last_seen).toLocaleString()
: 'Never'}
</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title="Fan Curves">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigate(`/servers/${server.id}/curves`);
}}
>
<SpeedIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton onClick={(e) => {
e.stopPropagation();
handleOpenDialog(server);
}}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
onClick={(e) => {
e.stopPropagation();
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="md"
fullWidth
fullScreen={false}
>
<DialogTitle>
{editingServer ? 'Edit Server' : 'Add Server'}
</DialogTitle>
<DialogContent>
{formError && (
<Alert severity="error" sx={{ mb: 2 }}>
{formError}
</Alert>
)}
{!editingServer && (
<Stepper activeStep={activeStep} sx={{ mb: 2 }}>
{STEPS.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
)}
{editingServer ? (
// Editing mode - show all fields at once
<Box>
<Typography variant="h6" gutterBottom>Basic Info</Typography>
<TextField fullWidth label="Name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} margin="normal" />
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>IPMI</Typography>
<TextField fullWidth label="IPMI Host" value={formData.ipmi_host} onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })} margin="normal" />
<TextField fullWidth label="IPMI Username" value={formData.ipmi_username} onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })} margin="normal" />
<TextField fullWidth type="password" label="IPMI Password (leave blank to keep)" value={formData.ipmi_password} onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })} margin="normal" />
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>SSH</Typography>
<FormControlLabel control={<Switch checked={formData.use_ssh} onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })} />} label="Enable SSH" />
{formData.use_ssh && (
<>
<TextField fullWidth label="SSH Host" value={formData.ssh_host} onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })} margin="normal" />
<TextField fullWidth label="SSH Username" value={formData.ssh_username} onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })} margin="normal" />
<TextField fullWidth type="password" label="SSH Password (leave blank to keep)" value={formData.ssh_password} onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })} margin="normal" />
</>
)}
</Box>
) : (
// Creating mode - show stepper
renderStepContent(activeStep)
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
{editingServer ? (
<Button onClick={handleSubmit} variant="contained" disabled={updateMutation.isPending}>
{updateMutation.isPending ? <CircularProgress size={24} /> : 'Update'}
</Button>
) : (
<>
<Button onClick={handleBack} disabled={activeStep === 0}>Back</Button>
{activeStep === STEPS.length - 1 ? (
<Button onClick={handleSubmit} variant="contained" disabled={createMutation.isPending}>
{createMutation.isPending ? <CircularProgress size={24} /> : 'Create'}
</Button>
) : (
<Button onClick={handleNext} variant="contained">Next</Button>
)}
</>
)}
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -0,0 +1,201 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Stepper,
Step,
StepLabel,
Alert,
CircularProgress,
} from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { setupApi } from '../utils/api';
const steps = ['Welcome', 'Create Admin Account'];
export default function SetupWizard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeStep, setActiveStep] = useState(0);
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: '',
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const setupMutation = useMutation({
mutationFn: () =>
setupApi.completeSetup(formData.username, formData.password, formData.confirmPassword),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/login');
},
});
const validateForm = () => {
const errors: Record<string, string> = {};
if (!formData.username || formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
if (!formData.password || formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = () => {
if (validateForm()) {
setupMutation.mutate();
}
};
const renderStepContent = () => {
switch (activeStep) {
case 0:
return (
<Box>
<Typography variant="h5" gutterBottom>
Welcome to IPMI Fan Control
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
This wizard will guide you through the initial setup of your IPMI Fan Control
application. You'll need to create an administrator account to get started.
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Features:
</Typography>
<ul>
<li>Control fan speeds on Dell T710 and compatible servers</li>
<li>Set custom fan curves based on temperature sensors</li>
<li>Automatic panic mode for safety</li>
<li>Monitor server health and power consumption</li>
<li>Support for multiple servers</li>
</ul>
<Button
variant="contained"
onClick={() => setActiveStep(1)}
sx={{ mt: 2 }}
>
Get Started
</Button>
</Box>
);
case 1:
return (
<Box>
<Typography variant="h5" gutterBottom>
Create Administrator Account
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This account will have full access to manage servers and fan control settings.
</Typography>
{setupMutation.error && (
<Alert severity="error" sx={{ mb: 2 }}>
{(setupMutation.error as any)?.response?.data?.detail || 'Setup failed'}
</Alert>
)}
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
error={!!formErrors.username}
helperText={formErrors.username}
margin="normal"
disabled={setupMutation.isPending}
/>
<TextField
fullWidth
type="password"
label="Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
error={!!formErrors.password}
helperText={formErrors.password}
margin="normal"
disabled={setupMutation.isPending}
/>
<TextField
fullWidth
type="password"
label="Confirm Password"
value={formData.confirmPassword}
onChange={(e) =>
setFormData({ ...formData, confirmPassword: e.target.value })
}
error={!!formErrors.confirmPassword}
helperText={formErrors.confirmPassword}
margin="normal"
disabled={setupMutation.isPending}
/>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
variant="outlined"
onClick={() => setActiveStep(0)}
disabled={setupMutation.isPending}
>
Back
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={setupMutation.isPending}
startIcon={setupMutation.isPending ? <CircularProgress size={20} /> : null}
>
Complete Setup
</Button>
</Box>
</Box>
);
default:
return null;
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'background.default',
}}
>
<Card sx={{ maxWidth: 600, width: '100%' }}>
<CardContent>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{renderStepContent()}
</CardContent>
</Card>
</Box>
);
}

View File

@ -0,0 +1,73 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '../types';
import { authApi } from '../utils/api';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
fetchUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await authApi.login(username, password);
const { access_token } = response.data;
localStorage.setItem('token', access_token);
set({ token: access_token, isAuthenticated: true });
await get().fetchUser();
} catch (error: any) {
set({
error: error.response?.data?.detail || 'Login failed',
isAuthenticated: false,
});
throw error;
} finally {
set({ isLoading: false });
}
},
logout: () => {
localStorage.removeItem('token');
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
});
},
fetchUser: async () => {
try {
const response = await authApi.getMe();
set({ user: response.data, isAuthenticated: true });
} catch (error) {
localStorage.removeItem('token');
set({ user: null, token: null, isAuthenticated: false });
}
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
)
);

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

@ -0,0 +1,151 @@
export interface User {
id: number;
username: string;
is_active: boolean;
created_at: string;
last_login: string | null;
}
// IPMI Settings
export interface IPMISettings {
ipmi_host: string;
ipmi_port: number;
ipmi_username: string;
ipmi_password: string;
}
// SSH Settings
export interface SSHSettings {
use_ssh: boolean;
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
ssh_password?: string;
ssh_key_file?: string;
}
export interface Server {
id: number;
name: string;
// IPMI
ipmi_host: string;
ipmi_port: number;
ipmi_username: string;
// SSH
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
use_ssh: boolean;
// Other
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;
}
// SSH Sensor Data
export interface SSHSensorData {
cpu_temps: {
cpu_name: string;
core_temps: Record<string, number>;
package_temp: number | null;
}[];
raw_data: Record<string, any>;
}
export interface SystemInfo {
cpu?: string;
memory?: string;
os?: string;
uptime?: string;
}

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

@ -0,0 +1,183 @@
import axios from 'axios';
import type {
User,
Server,
FanCurve,
FanCurvePoint,
TemperatureReading,
FanReading,
SensorReading,
SystemLog,
DashboardStats,
ServerStatus,
ServerSensors,
FanControlCommand,
AutoControlSettings,
SSHSensorData,
SystemInfo,
} 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;
vendor: string;
ipmi: {
ipmi_host: string;
ipmi_port: number;
ipmi_username: string;
ipmi_password: string;
};
ssh: {
use_ssh: boolean;
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
ssh_password?: string;
ssh_key_file?: string;
};
}) => api.post<Server>('/servers', data),
update: (id: number, data: any) =>
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`),
// SSH endpoints
getSSHSensors: (id: number) => api.get<SSHSensorData>(`/servers/${id}/ssh/sensors`),
getSSHSystemInfo: (id: number) => api.get<SystemInfo>(`/servers/${id}/ssh/system-info`),
executeSSHCommand: (id: number, command: string) =>
api.post<{ exit_code: number; stdout: string; stderr: string }>(`/servers/${id}/ssh/execute`, { command }),
};
// 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'),
getServersOverview: () =>
api.get<{ servers: Array<{
id: number;
name: string;
vendor: string;
is_active: boolean;
manual_control_enabled: boolean;
auto_control_enabled: boolean;
max_temp: number | null;
avg_fan_speed: number | null;
power_consumption: number | null;
last_updated: string | null;
cached: boolean;
}> }>('/dashboard/servers-overview'),
refreshServer: (serverId: number) =>
api.post<{ success: boolean; message: string }>(`/dashboard/refresh-server/${serverId}`),
getServerData: (serverId: number) =>
api.get<{
server: Server;
current_temperatures: TemperatureReading[];
current_fans: FanReading[];
recent_sensor_data: SensorReading[];
recent_fan_data: FanReading[];
power_consumption: Record<string, string> | null;
}>(`/dashboard/servers/${serverId}`),
};
// Logs API
export const logsApi = {
getAll: (params?: { server_id?: number; event_type?: string; limit?: number }) =>
api.get<SystemLog[]>('/logs', { params }),
};
export default api;

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

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

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

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

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

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})

View File

@ -1,215 +0,0 @@
#!/bin/bash
# IPMI Controller - Install Script with Persistence
# This sets up auto-start and ensures settings persist
set -e
INSTALL_DIR="${1:-/opt/ipmi-controller}"
DATA_DIR="$INSTALL_DIR/data"
SERVICE_NAME="ipmi-controller"
USER="${SUDO_USER:-$USER}"
echo "🌡️ IPMI Controller Installation"
echo "================================"
echo "Install dir: $INSTALL_DIR"
echo "Data dir: $DATA_DIR"
echo "Service user: $USER"
echo ""
# Check if running as root for system-wide install
if [ "$EUID" -ne 0 ]; then
echo "⚠️ Not running as root. Installing to $HOME/ipmi-controller instead."
INSTALL_DIR="$HOME/ipmi-controller"
DATA_DIR="$INSTALL_DIR/data"
SYSTEM_INSTALL=false
else
SYSTEM_INSTALL=true
fi
# Create directories
echo "📁 Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$DATA_DIR"
# Copy files
echo "📋 Copying files..."
cp -r . "$INSTALL_DIR/" 2>/dev/null || true
# Ensure data directory exists with proper files
if [ ! -f "$DATA_DIR/config.json" ]; then
echo "⚙️ Creating default config..."
cat > "$DATA_DIR/config.json" << 'EOF'
{
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
"http_sensor_enabled": false,
"http_sensor_url": "",
"http_sensor_timeout": 10,
"enabled": false,
"poll_interval": 10,
"min_speed": 10,
"max_speed": 100,
"panic_temp": 85,
"panic_speed": 100,
"panic_on_no_data": true,
"no_data_timeout": 60,
"primary_sensor": "cpu",
"sensor_preference": "auto",
"fans": {},
"fan_groups": {},
"fan_curves": {
"Balanced": {
"points": [
{"temp": 30, "speed": 10},
{"temp": 35, "speed": 12},
{"temp": 40, "speed": 15},
{"temp": 45, "speed": 20},
{"temp": 50, "speed": 30},
{"temp": 55, "speed": 40},
{"temp": 60, "speed": 55},
{"temp": 65, "speed": 70},
{"temp": 70, "speed": 85},
{"temp": 75, "speed": 95},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{"temp": 30, "speed": 5},
{"temp": 40, "speed": 10},
{"temp": 50, "speed": 15},
{"temp": 55, "speed": 25},
{"temp": 60, "speed": 35},
{"temp": 65, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 75, "speed": 85},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{"temp": 30, "speed": 20},
{"temp": 35, "speed": 25},
{"temp": 40, "speed": 35},
{"temp": 45, "speed": 45},
{"temp": 50, "speed": 55},
{"temp": 55, "speed": 70},
{"temp": 60, "speed": 85},
{"temp": 65, "speed": 95},
{"temp": 70, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
"active_curve": "Balanced",
"theme": "dark"
}
EOF
fi
if [ ! -f "$DATA_DIR/users.json" ]; then
echo "👤 Creating users file..."
echo '{"users": {}}' > "$DATA_DIR/users.json"
fi
# Install Python dependencies
echo "🐍 Installing dependencies..."
if [ "$SYSTEM_INSTALL" = true ]; then
pip3 install -q -r "$INSTALL_DIR/requirements.txt" || pip install -q -r "$INSTALL_DIR/requirements.txt"
else
pip3 install --user -q -r "$INSTALL_DIR/requirements.txt" 2>/dev/null || true
fi
# Install ipmitool if not present
if ! command -v ipmitool &> /dev/null; then
echo "📦 Installing ipmitool..."
if [ "$SYSTEM_INSTALL" = true ]; then
apt-get update -qq && apt-get install -y -qq ipmitool
else
echo "⚠️ Please install ipmitool manually: sudo apt-get install ipmitool"
fi
else
echo "✓ ipmitool already installed"
fi
# Create systemd service (system-wide only)
if [ "$SYSTEM_INSTALL" = true ]; then
echo "🔧 Creating systemd service..."
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
[Unit]
Description=IPMI Controller
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
Environment="PYTHONUNBUFFERED=1"
Environment="DATA_DIR=$DATA_DIR"
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
ExecStop=/bin/kill -TERM \$MAINPID
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
# Set proper ownership
chown -R "$USER:$USER" "$INSTALL_DIR"
echo ""
echo "✅ Installation complete!"
echo ""
echo "Start the service:"
echo " sudo systemctl start $SERVICE_NAME"
echo ""
echo "Check status:"
echo " sudo systemctl status $SERVICE_NAME"
echo ""
echo "View logs:"
echo " sudo journalctl -u $SERVICE_NAME -f"
echo ""
echo "Access: http://$(hostname -I | awk '{print $1}'):8000"
else
# User install - create a simple start script
cat > "$INSTALL_DIR/start.sh" << 'EOF'
#!/bin/bash
cd "$(dirname "$0")"
export DATA_DIR="./data"
export PYTHONUNBUFFERED=1
echo "Starting IPMI Controller..."
echo "Data directory: $DATA_DIR"
python3 web_server.py
EOF
chmod +x "$INSTALL_DIR/start.sh"
echo ""
echo "✅ User installation complete!"
echo ""
echo "Start manually:"
echo " cd $INSTALL_DIR && ./start.sh"
echo ""
echo "Or create a systemd service manually:"
echo " nano ~/.config/systemd/user/ipmi-controller.service"
echo ""
echo "Access: http://localhost:8000"
fi
echo ""
echo "📁 Your settings are stored in: $DATA_DIR"
echo " - config.json: All configuration"
echo " - users.json: User accounts"
echo ""
echo "💾 These files persist across restarts and updates!"

View File

@ -1,16 +0,0 @@
[Unit]
Description=IPMI Controller - Advanced Fan Control
After=network.target
Requires=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/ipmi-controller
ExecStartPre=/bin/sh -c 'command -v ipmitool >/dev/null 2>&1 || { echo "ipmitool is required but not installed"; exit 1; }'
ExecStart=/usr/bin/python3 /opt/ipmi-controller/web_server.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@ -1,4 +0,0 @@
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
requests>=2.31.0

View File

@ -1,30 +0,0 @@
#!/usr/bin/env python3
"""Reset password for fan controller"""
import json
import hashlib
import sys
USERS_FILE = "/home/devmatrix/projects/fan-controller-v2/data/users.json"
def hash_password(password):
return hashlib.sha256(password.encode()).hexdigest()
if len(sys.argv) != 3:
print("Usage: reset_password.py <username> <new_password>")
sys.exit(1)
username = sys.argv[1]
password = sys.argv[2]
try:
with open(USERS_FILE) as f:
data = json.load(f)
data["users"][username] = hash_password(password)
with open(USERS_FILE, 'w') as f:
json.dump(data, f)
print(f"Password reset for user: {username}")
except Exception as e:
print(f"Error: {e}")

View File

@ -1,45 +0,0 @@
#!/usr/bin/env python3
"""
Simple HTTP server for lm-sensors data
Run this on your Proxmox host or server with lm-sensors installed
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import subprocess
import sys
class SensorHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/sensors':
try:
result = subprocess.run(['sensors', '-j'],
capture_output=True, text=True)
data = json.loads(result.stdout)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress logs
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
server = HTTPServer(('0.0.0.0', port), SensorHandler)
print(f"Sensor server running on http://0.0.0.0:{port}/sensors")
print("Press Ctrl+C to stop")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()

View File

@ -1,785 +0,0 @@
INFO: Started server process [105401]
INFO: Waiting for application startup.
2026-02-20 22:36:05,954 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json
2026-02-20 22:36:05,955 - __main__ - INFO - Auto-starting fan control (enabled in config)
2026-02-20 22:36:06,109 - fan_controller - INFO - Connected to IPMI at 192.168.5.191
2026-02-20 22:36:06,110 - fan_controller - INFO - HTTP sensor client initialized for http://192.168.5.200:8888
2026-02-20 22:36:06,111 - fan_controller - INFO - IPMI Controller service started
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
2026-02-20 22:36:06,301 - fan_controller - INFO - Manual fan control enabled
2026-02-20 22:36:11,732 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:36:11,732 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
INFO: 192.168.5.30:57657 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
return HTMLResponse(content=get_html(theme))
^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<style>body{padding-bottom:80px !important;}</style>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:65161 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:65161 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
return HTMLResponse(content=get_html(theme))
^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<style>body{padding-bottom:80px !important;}</style>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:51526 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:53588 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
return HTMLResponse(content=get_html(theme))
^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<style>body{padding-bottom:80px !important;}</style>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:58112 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:61736 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
if not user_manager.is_setup_complete():
^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:64381 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:64381 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
if not user_manager.is_setup_complete():
^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:59631 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
if not user_manager.is_setup_complete():
^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:56967 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:56967 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
if not user_manager.is_setup_complete():
^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:51136 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:51136 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
if not user_manager.is_setup_complete():
^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
^^^^^^^
NameError: name 'padding' is not defined
2026-02-20 22:45:21,250 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:45:21,250 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 22:45:36,965 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:45:36,965 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
2026-02-20 22:46:56,801 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:46:56,801 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 22:47:12,899 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:47:12,900 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
2026-02-20 22:48:44,782 - fan_controller - INFO - Fan 0xff speed set to 16%
2026-02-20 22:48:44,783 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
2026-02-20 22:49:00,730 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:49:00,730 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 22:49:16,428 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:49:16,428 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
2026-02-20 22:52:28,497 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:52:28,497 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 22:52:44,134 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:52:44,134 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
INFO: 192.168.5.30:57785 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
</form>
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
const data = {{
^^^^^^^
NameError: name 'padding' is not defined
INFO: 192.168.5.30:50967 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:52925 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
</form>
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
const data = {{
^^^^^^^
NameError: name 'padding' is not defined
2026-02-20 22:55:50,605 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:55:50,606 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 22:56:06,087 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:56:06,087 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
2026-02-20 22:58:24,641 - fan_controller - INFO - Fan 0xff speed set to 16%
2026-02-20 22:58:24,642 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
INFO: 192.168.5.30:56982 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
</form>
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
const data = {{
^^^^^^^
NameError: name 'padding' is not defined
2026-02-20 22:58:40,511 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 22:58:40,511 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
2026-02-20 22:59:56,756 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 22:59:56,756 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 23:00:13,621 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 23:00:13,622 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
2026-02-20 23:00:28,792 - fan_controller - INFO - Fan 0xff speed set to 16%
2026-02-20 23:00:28,792 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
2026-02-20 23:00:45,314 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 23:00:45,315 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
2026-02-20 23:01:17,971 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 23:01:17,971 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 23:01:33,427 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 23:01:33,428 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
INFO: 192.168.5.30:53747 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
</form>
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
const data = {{
^^^^^^^
NameError: name 'padding' is not defined
2026-02-20 23:03:08,820 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 23:03:08,820 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 23:03:26,001 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 23:03:26,001 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
2026-02-20 23:03:58,639 - fan_controller - INFO - Fan 0xff speed set to 15%
2026-02-20 23:03:58,639 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
2026-02-20 23:04:14,022 - fan_controller - INFO - Fan 0xff speed set to 14%
2026-02-20 23:04:14,022 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
INFO: 192.168.5.30:49653 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
</form>
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
const data = {{
^^^^^^^
NameError: name 'padding' is not defined
2026-02-20 23:06:01,019 - fan_controller - WARNING - IPMI command failed:
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [105401]

View File

@ -1,117 +0,0 @@
#!/bin/bash
# IPMI Controller - lm-sensors HTTP Server Setup
# Run this on your Proxmox/Dell server to expose sensors over HTTP
set -e
echo "🌡️ IPMI Controller - lm-sensors HTTP Setup"
echo "============================================"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "❌ Please run as root (sudo)"
exit 1
fi
# Install lm-sensors if not present
echo ""
echo "📦 Checking lm-sensors..."
if ! command -v sensors &> /dev/null; then
echo "Installing lm-sensors..."
apt-get update
apt-get install -y lm-sensors
echo ""
echo "🔧 Running sensors-detect..."
echo "Answer YES to all questions or use default values"
sensors-detect --auto
else
echo "✓ lm-sensors already installed"
fi
# Install netcat if not present
if ! command -v nc &> /dev/null; then
echo "Installing netcat..."
apt-get install -y netcat-openbsd
fi
# Create the HTTP sensors server script
SERVER_SCRIPT="/usr/local/bin/sensors-http-server.sh"
echo ""
echo "📝 Creating HTTP server script..."
cat > "$SERVER_SCRIPT" << 'EOF'
#!/bin/bash
# lm-sensors HTTP Server for IPMI Controller
# Serves sensor data on port 8888
PORT=${1:-8888}
echo "Starting lm-sensors HTTP server on port $PORT..."
echo "Access via: http://$(hostname -I | awk '{print $1}'):$PORT"
while true; do
{
echo -e "HTTP/1.1 200 OK\r"
echo -e "Content-Type: text/plain\r"
echo -e "Access-Control-Allow-Origin: *\r"
echo -e "\r"
sensors -u 2>/dev/null || echo "Error reading sensors"
} | nc -l -p "$PORT" -q 1
done
EOF
chmod +x "$SERVER_SCRIPT"
echo "✓ Created $SERVER_SCRIPT"
# Create systemd service
SERVICE_FILE="/etc/systemd/system/sensors-http.service"
echo ""
echo "🔧 Creating systemd service..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=lm-sensors HTTP Server for IPMI Controller
After=network.target
[Service]
Type=simple
ExecStart=$SERVER_SCRIPT 8888
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Created $SERVICE_FILE"
# Reload systemd and enable service
echo ""
echo "🚀 Enabling and starting service..."
systemctl daemon-reload
systemctl enable sensors-http.service
systemctl start sensors-http.service
# Check status
sleep 2
if systemctl is-active --quiet sensors-http.service; then
echo "✓ Service is running!"
echo ""
echo "🌐 HTTP Endpoint: http://$(hostname -I | awk '{print $1}'):8888"
echo ""
echo "Test with: curl http://$(hostname -I | awk '{print $1}'):8888"
else
echo "⚠️ Service failed to start. Check logs:"
echo " journalctl -u sensors-http.service -f"
fi
echo ""
echo "📋 Management Commands:"
echo " Start: sudo systemctl start sensors-http"
echo " Stop: sudo systemctl stop sensors-http"
echo " Status: sudo systemctl status sensors-http"
echo " Logs: sudo journalctl -u sensors-http -f"
echo ""
echo "✅ Setup complete!"

View File

@ -1,85 +0,0 @@
<!-- Modern, clean SVG icons for IPMI Controller -->
<svg style="display:none;" xmlns="http://www.w3.org/2000/svg">
<!-- Thermometer Icon -->
<symbol id="icon-thermometer" viewBox="0 0 24 24">
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="11.5" cy="18.5" r="2" fill="currentColor"/>
</symbol>
<!-- Server Icon -->
<symbol id="icon-server" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="2" y="11" width="20" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="2" y="19" width="20" height="3" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="6" cy="6" r="1" fill="currentColor"/>
<circle cx="6" cy="14" r="1" fill="currentColor"/>
</symbol>
<!-- Fan Icon -->
<symbol id="icon-fan" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 12c0-3 2-5 5-5s5 2 5 5-2 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 12c0 3-2 5-5 5s-5-2-5-5 2-5 5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 12c3 0 5-2 5-5s-2-5-5-5-5 2-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 12c-3 0-5 2-5 5s2 5 5 5 5-2 5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</symbol>
<!-- Clock/Mode Icon -->
<symbol id="icon-clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 7v5l3 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<!-- Sensors/List Icon -->
<symbol id="icon-sensors" viewBox="0 0 24 24">
<path d="M3 6h18M3 12h18M3 18h18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="7" cy="6" r="1.5" fill="currentColor"/>
<circle cx="7" cy="12" r="1.5" fill="currentColor"/>
<circle cx="7" cy="18" r="1.5" fill="currentColor"/>
</symbol>
<!-- Lock/Password Icon -->
<symbol id="icon-lock" viewBox="0 0 24 24">
<rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</symbol>
<!-- Logout Icon -->
<symbol id="icon-logout" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 17l5-5-5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 12H9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<!-- Sun/Theme Icon -->
<symbol id="icon-sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</symbol>
<!-- Controls/Settings Icon -->
<symbol id="icon-controls" viewBox="0 0 24 24">
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="18" cy="6" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="18" cy="18" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M9 12h3M15 6h-3M15 18h-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</symbol>
<!-- Search/Identify Icon -->
<symbol id="icon-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M21 21l-4.35-4.35" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<!-- Check/Success Icon -->
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<!-- X/Error Icon -->
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/>
</svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</svg>

Before

Width:  |  Height:  |  Size: 365 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g></svg>

Before

Width:  |  Height:  |  Size: 279 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 667 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 281 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 501 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 395 B

View File

@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4"/>
<path d="M12 18v4"/>
<path d="M4.93 4.93l2.83 2.83"/>
<path d="M16.24 16.24l2.83 2.83"/>
<path d="M2 12h4"/>
<path d="M18 12h4"/>
<path d="M4.93 19.07l2.83-2.83"/>
<path d="M16.24 7.76l2.83-2.83"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

View File

@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.07"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>fan-blades</title><path class="cls-1" d="M67.29,82.9c-.11,1.3-.26,2.6-.47,3.9-1.43,9-5.79,14.34-8.08,22.17C56,118.45,65.32,122.53,73.27,122A37.63,37.63,0,0,0,85,119a45,45,0,0,0,9.32-5.36c20.11-14.8,16-34.9-6.11-46.36a15,15,0,0,0-4.14-1.4,22,22,0,0,1-6,11.07l0,0A22.09,22.09,0,0,1,67.29,82.9ZM62.4,44.22a17.1,17.1,0,1,1-17.1,17.1,17.1,17.1,0,0,1,17.1-17.1ZM84.06,56.83c1.26.05,2.53.14,3.79.29,9.06,1,14.58,5.16,22.5,7.1,9.6,2.35,13.27-7.17,12.41-15.09a37.37,37.37,0,0,0-3.55-11.57,45.35,45.35,0,0,0-5.76-9.08C97.77,9,77.88,14,67.4,36.63a14.14,14.14,0,0,0-1,2.94A22,22,0,0,1,78,45.68l0,0a22.07,22.07,0,0,1,6,11.13Zm-26.9-17c0-1.6.13-3.21.31-4.81,1-9.07,5.12-14.6,7-22.52C66.86,2.89,57.32-.75,49.41.13A37.4,37.4,0,0,0,37.84,3.7a44.58,44.58,0,0,0-9.06,5.78C9.37,25.2,14.39,45.08,37,55.51a14.63,14.63,0,0,0,3.76,1.14A22.12,22.12,0,0,1,57.16,39.83ZM40.66,65.42a52.11,52.11,0,0,1-5.72-.24c-9.08-.88-14.67-4.92-22.62-6.73C2.68,56.25-.83,65.84.16,73.74A37.45,37.45,0,0,0,3.9,85.25a45.06,45.06,0,0,0,5.91,9c16,19.17,35.8,13.87,45.91-8.91a15.93,15.93,0,0,0,.88-2.66A22.15,22.15,0,0,1,40.66,65.42Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.07"><path fill="#2196f3" d="M67.29,82.9c-.11,1.3-.26,2.6-.47,3.9-1.43,9-5.79,14.34-8.08,22.17C56,118.45,65.32,122.53,73.27,122A37.63,37.63,0,0,0,85,119a45,45,0,0,0,9.32-5.36c20.11-14.8,16-34.9-6.11-46.36a15,15,0,0,0-4.14-1.4,22,22,0,0,1-6,11.07l0,0A22.09,22.09,0,0,1,67.29,82.9ZM62.4,44.22a17.1,17.1,0,1,1-17.1,17.1,17.1,17.1,0,0,1,17.1-17.1ZM84.06,56.83c1.26.05,2.53.14,3.79.29,9.06,1,14.58,5.16,22.5,7.1,9.6,2.35,13.27-7.17,12.41-15.09a37.37,37.37,0,0,0-3.55-11.57,45.35,45.35,0,0,0-5.76-9.08C97.77,9,77.88,14,67.4,36.63a14.14,14.14,0,0,0-1,2.94A22,22,0,0,1,78,45.68l0,0a22.07,22.07,0,0,1,6,11.13Zm-26.9-17c0-1.6.13-3.21.31-4.81,1-9.07,5.12-14.6,7-22.52C66.86,2.89,57.32-.75,49.41.13A37.4,37.4,0,0,0,37.84,3.7a44.58,44.58,0,0,0-9.06,5.78C9.37,25.2,14.39,45.08,37,55.51a14.63,14.63,0,0,0,3.76,1.14A22.12,22.12,0,0,1,57.16,39.83ZM40.66,65.42a52.11,52.11,0,0,1-5.72-.24c-9.08-.88-14.67-4.92-22.62-6.73C2.68,56.25-.83,65.84.16,73.74A37.45,37.45,0,0,0,3.9,85.25a45.06,45.06,0,0,0,5.91,9c16,19.17,35.8,13.87,45.91-8.91a15.93,15.93,0,0,0,.88-2.66A22.15,22.15,0,0,1,40.66,65.42Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 540 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/>
</svg>

Before

Width:  |  Height:  |  Size: 694 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 371 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 394 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 591 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 0 1 0-5.304m5.304 0a3.75 3.75 0 0 1 0 5.304m-7.425 2.121a6.75 6.75 0 0 1 0-9.546m9.546 0a6.75 6.75 0 0 1 0 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 517 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 830 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/></svg>

Before

Width:  |  Height:  |  Size: 242 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 3.75H6A2.25 2.25 0 0 0 3.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0 1 20.25 6v1.5m0 9V18A2.25 2.25 0 0 1 18 20.25h-1.5m-9 0H6A2.25 2.25 0 0 1 3.75 18v-1.5M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 406 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 396 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 288 B

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# Tests for IPMI Fan Control

49
tests/test_auth.py Normal file
View File

@ -0,0 +1,49 @@
"""Tests for authentication module."""
import pytest
from backend.auth import (
get_password_hash,
verify_password,
create_access_token,
decode_access_token,
encrypt_password,
decrypt_password,
)
def test_password_hashing():
"""Test password hashing and verification."""
password = "testpassword123"
hashed = get_password_hash(password)
assert hashed != password
assert verify_password(password, hashed)
assert not verify_password("wrongpassword", hashed)
def test_jwt_tokens():
"""Test JWT token creation and decoding."""
data = {"sub": "testuser"}
token = create_access_token(data)
assert token is not None
decoded = decode_access_token(token)
assert decoded is not None
assert decoded["sub"] == "testuser"
def test_invalid_token():
"""Test decoding invalid token."""
decoded = decode_access_token("invalid.token.here")
assert decoded is None
def test_password_encryption():
"""Test password encryption and decryption."""
password = "secret_password"
encrypted = encrypt_password(password)
assert encrypted != password
decrypted = decrypt_password(encrypted)
assert decrypted == password

83
tests/test_fan_curve.py Normal file
View File

@ -0,0 +1,83 @@
"""Tests for fan curve logic."""
import pytest
from backend.fan_control import FanCurveManager, FanCurvePoint
def test_parse_curve():
"""Test parsing fan curve from JSON."""
json_data = '[{"temp": 30, "speed": 20}, {"temp": 50, "speed": 50}, {"temp": 70, "speed": 100}]'
curve = FanCurveManager.parse_curve(json_data)
assert len(curve) == 3
assert curve[0].temp == 30
assert curve[0].speed == 20
def test_parse_invalid_curve():
"""Test parsing invalid curve returns default."""
curve = FanCurveManager.parse_curve("invalid json")
assert len(curve) == 6 # Default curve has 6 points
assert curve[0].temp == 30
assert curve[0].speed == 10
def test_calculate_speed_below_min():
"""Test speed calculation below minimum temperature."""
curve = [
FanCurvePoint(30, 10),
FanCurvePoint(50, 50),
FanCurvePoint(70, 100),
]
speed = FanCurveManager.calculate_speed(curve, 20)
assert speed == 10
def test_calculate_speed_above_max():
"""Test speed calculation above maximum temperature."""
curve = [
FanCurvePoint(30, 10),
FanCurvePoint(50, 50),
FanCurvePoint(70, 100),
]
speed = FanCurveManager.calculate_speed(curve, 80)
assert speed == 100
def test_calculate_speed_interpolation():
"""Test speed calculation with interpolation."""
curve = [
FanCurvePoint(30, 10),
FanCurvePoint(50, 50),
FanCurvePoint(70, 100),
]
# At 40°C, should be halfway between 10% and 50%
speed = FanCurveManager.calculate_speed(curve, 40)
assert speed == 30
def test_calculate_speed_exact_point():
"""Test speed calculation at exact curve point."""
curve = [
FanCurvePoint(30, 10),
FanCurvePoint(50, 50),
]
speed = FanCurveManager.calculate_speed(curve, 50)
assert speed == 50
def test_serialize_curve():
"""Test serializing curve to JSON."""
points = [
FanCurvePoint(30, 10),
FanCurvePoint(50, 50),
]
json_data = FanCurveManager.serialize_curve(points)
assert "30" in json_data
assert "10" in json_data
assert "50" in json_data

85
tests/test_ipmi_client.py Normal file
View File

@ -0,0 +1,85 @@
"""Tests for IPMI client."""
import pytest
from unittest.mock import Mock, patch
from backend.ipmi_client import IPMIClient, TemperatureReading
def test_fan_mapping():
"""Test fan ID mapping."""
assert IPMIClient.FAN_MAPPING["0x00"] == 1
assert IPMIClient.FAN_MAPPING["0x06"] == 7
def test_hex_to_percent_conversion():
"""Test hex to percent conversion mapping."""
assert IPMIClient.HEX_TO_PERCENT["0x00"] == 0
assert IPMIClient.HEX_TO_PERCENT["0x64"] == 100
assert IPMIClient.PERCENT_TO_HEX[50] == "0x32"
assert IPMIClient.PERCENT_TO_HEX[100] == "0x64"
def test_determine_temp_location():
"""Test temperature location detection."""
client = IPMIClient("192.168.1.1", "user", "pass")
assert client._determine_temp_location("CPU1 Temp") == "cpu1"
assert client._determine_temp_location("CPU2 Temp") == "cpu2"
assert client._determine_temp_location("Processor 1") == "cpu1"
assert client._determine_temp_location("Inlet Temp") == "inlet"
assert client._determine_temp_location("Exhaust Temp") == "exhaust"
assert client._determine_temp_location("DIMM 1") == "memory"
assert client._determine_temp_location("Unknown Sensor") == "other"
def test_determine_sensor_type():
"""Test sensor type detection."""
client = IPMIClient("192.168.1.1", "user", "pass")
assert client._determine_sensor_type("CPU Temp") == "temperature"
assert client._determine_sensor_type("Fan 1 RPM") == "fan"
assert client._determine_sensor_type("12V") == "voltage"
assert client._determine_sensor_type("Power Supply") == "power"
def test_parse_sensor_value():
"""Test sensor value parsing."""
client = IPMIClient("192.168.1.1", "user", "pass")
value, unit = client._parse_sensor_value("45 degrees C")
assert value == 45.0
assert unit == "°C"
value, unit = client._parse_sensor_value("4200 RPM")
assert value == 4200.0
assert unit == "RPM"
value, unit = client._parse_sensor_value("12.05 Volts")
assert value == 12.05
assert unit == "V"
value, unit = client._parse_sensor_value("250 Watts")
assert value == 250.0
assert unit == "W"
@patch('backend.ipmi_client.subprocess.run')
def test_test_connection(mock_run):
"""Test connection test method."""
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
client = IPMIClient("192.168.1.1", "user", "pass")
result = client.test_connection()
assert result is True
mock_run.assert_called_once()
@patch('backend.ipmi_client.subprocess.run')
def test_test_connection_failure(mock_run):
"""Test connection test with failure."""
mock_run.return_value = Mock(returncode=1, stdout="", stderr="Error")
client = IPMIClient("192.168.1.1", "user", "pass")
result = client.test_connection()
assert result is False

File diff suppressed because it is too large Load Diff