Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -1,70 +0,0 @@
|
|||
# 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
|
|
@ -1,16 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,49 +1,51 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Data and databases
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# IDE
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Application data
|
||||
data/
|
||||
*.json
|
||||
!data/.gitkeep
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
# Temp files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
71
Dockerfile
|
|
@ -1,71 +0,0 @@
|
|||
# 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
|
|
@ -1,21 +0,0 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# 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
|
|
@ -1,237 +1,406 @@
|
|||
# IPMI Fan Control
|
||||
# IPMI Controller
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||
**Version:** 1.0.0
|
||||
**Author:** ImpulsiveFPS
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🖥️ **Multiple Server Support** - Manage multiple servers from a single interface
|
||||
- 🌡️ **Temperature-Based Fan Curves** - Automatically adjust fan speeds based on CPU temperatures
|
||||
- ⚡ **Panic Mode** - Automatically sets fans to 100% if sensor data is lost
|
||||
- 🎛️ **Manual Fan Control** - Direct control over individual fans or all fans at once
|
||||
- 📊 **Real-time Monitoring** - View temperatures, fan speeds, and power consumption
|
||||
- 🔒 **Secure Authentication** - JWT-based authentication with encrypted passwords
|
||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose
|
||||
- 🔄 **Persistent Storage** - Settings and credentials survive container restarts
|
||||
- 🌡️ **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
|
||||
|
||||
## Supported Servers
|
||||
---
|
||||
|
||||
- Dell PowerEdge T710 (tested)
|
||||
- Dell PowerEdge R710/R720/R730 (should work)
|
||||
- Dell PowerEdge R810/R820/R910/R920 (should work)
|
||||
- HPE servers with iLO (partial support)
|
||||
## Table of Contents
|
||||
|
||||
## Quick Start
|
||||
- [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)
|
||||
|
||||
### Prerequisites
|
||||
---
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- IPMI enabled on your server(s) with network access
|
||||
## Prerequisites
|
||||
|
||||
### Installation
|
||||
### 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
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://git.lemonlink.eu/impulsivefps/ipmi-fan-control.git
|
||||
cd ipmi-fan-control
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install ipmitool
|
||||
|
||||
# Verify installation
|
||||
ipmitool -V
|
||||
```
|
||||
|
||||
2. Copy the example environment file and edit it:
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set a secure SECRET_KEY
|
||||
git clone https://github.com/ImpulsiveFPS/IPMI-Controller.git
|
||||
cd IPMI-Controller
|
||||
```
|
||||
|
||||
3. Create the data directory:
|
||||
### 2. Install Python Dependencies
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Start the application:
|
||||
Required packages:
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- pydantic
|
||||
- requests
|
||||
|
||||
### 3. Start the Application
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
python3 web_server.py
|
||||
```
|
||||
|
||||
5. Access the web interface at `http://your-server-ip:8000`
|
||||
The web interface will be available at `http://localhost:8000`
|
||||
|
||||
6. Complete the setup wizard to create your admin account
|
||||
### 4. (Optional) Systemd Service
|
||||
|
||||
### First Time Setup
|
||||
To run the controller as a system service:
|
||||
|
||||
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.)
|
||||
```bash
|
||||
sudo cp ipmi-controller.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ipmi-controller
|
||||
sudo systemctl start ipmi-controller
|
||||
```
|
||||
|
||||
## Usage
|
||||
---
|
||||
|
||||
### Manual Fan Control
|
||||
## IPMI Setup
|
||||
|
||||
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
|
||||
### Step 1: Configure iDRAC/IPMI Network Settings
|
||||
|
||||
### Automatic Fan Curves
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
### Panic Mode
|
||||
### Step 2: Create IPMI User
|
||||
|
||||
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.
|
||||
#### Method 1: Via iDRAC Web Interface (Recommended)
|
||||
|
||||
## IPMI Commands Used
|
||||
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
|
||||
|
||||
This application uses the following IPMI raw commands:
|
||||
#### Method 2: Via ipmitool (Local Access Required)
|
||||
|
||||
### Dell Servers
|
||||
```bash
|
||||
# List current users
|
||||
sudo ipmitool user list 1
|
||||
|
||||
- **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`
|
||||
# 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
|
||||
|
||||
### Standard IPMI
|
||||
# Verify
|
||||
sudo ipmitool user list 1
|
||||
```
|
||||
|
||||
- **Get Temperatures**: `sdr type temperature`
|
||||
- **Get All Sensors**: `sdr elist full`
|
||||
- **Get Power Supply Status**: `sdr type 'Power Supply'`
|
||||
- **Dell Power Monitor**: `delloem powermonitor`
|
||||
### Step 3: Enable IPMI over LAN
|
||||
|
||||
## Fan Mapping
|
||||
```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
|
||||
|
||||
For Dell servers, the fan mapping is:
|
||||
# Verify settings
|
||||
sudo ipmitool lan print 1
|
||||
```
|
||||
|
||||
| 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 |
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
### Fan Curves
|
||||
|
||||
| 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 |
|
||||
Fan curves define how fan speed responds to temperature:
|
||||
|
||||
### Docker Compose
|
||||
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
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
### Fan Groups
|
||||
|
||||
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
|
||||
```
|
||||
Groups allow unified control of multiple fans:
|
||||
|
||||
## Building from Source
|
||||
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
|
||||
|
||||
### Backend
|
||||
### Quick Controls
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python -m uvicorn main:app --reload
|
||||
```
|
||||
- **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
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The application consists of:
|
||||
|
||||
- **Backend**: Python FastAPI with SQLAlchemy ORM
|
||||
- **Frontend**: React with TypeScript, Material-UI, and Recharts
|
||||
- **Database**: SQLite (default) or PostgreSQL
|
||||
- **IPMI**: Direct integration with ipmitool
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Change the default SECRET_KEY** in production
|
||||
2. Use HTTPS when accessing over the internet
|
||||
3. Place behind a reverse proxy (nginx, traefik) for SSL termination
|
||||
4. Use strong passwords for the admin account
|
||||
5. Server passwords are encrypted at rest using Fernet encryption
|
||||
6. Regularly update the Docker image for security patches
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot connect to server
|
||||
### Connection Issues
|
||||
|
||||
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)
|
||||
**"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
|
||||
|
||||
### Fan control not working
|
||||
**"Connection timeout":**
|
||||
- Check firewall rules on IPMI network
|
||||
- Verify port 623 is open
|
||||
- Try increasing timeout in settings
|
||||
|
||||
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
|
||||
### Fan Control Not Working
|
||||
|
||||
### Container won't start
|
||||
**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
|
||||
|
||||
1. Check logs: `docker-compose logs -f`
|
||||
2. Verify data directory has correct permissions
|
||||
3. Ensure port 8000 is not already in use
|
||||
**"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
|
||||
|
||||
## License
|
||||
### HTTP Sensor Issues
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
**"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
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues and feature requests, please use the GitHub issue tracker.
|
||||
- 🐛 **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
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Dell for the IPMI command documentation
|
||||
- The ipmitool project
|
||||
- FastAPI and React communities
|
||||
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**
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
# 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 +0,0 @@
|
|||
# IPMI Fan Control Backend
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,529 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
"""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
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
#!/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"
|
||||
)
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
"""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]]
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
#!/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
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"users": {"admin": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae"}}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
#!/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'"
|
||||
|
|
@ -1,26 +1,20 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
ipmi-fan-control:
|
||||
ipmi-controller:
|
||||
build: .
|
||||
container_name: ipmi-fan-control
|
||||
container_name: ipmi-controller
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
- DATA_DIR=/app/data
|
||||
- PANIC_TIMEOUT_SECONDS=${PANIC_TIMEOUT_SECONDS:-60}
|
||||
- PANIC_FAN_SPEED=${PANIC_FAN_SPEED:-100}
|
||||
- DEBUG=${DEBUG:-true}
|
||||
- PYTHONUNBUFFERED=1
|
||||
volumes:
|
||||
# Persist data directory
|
||||
- ./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
|
||||
# Optional: mount ipmitool from host if needed
|
||||
- /usr/bin/ipmitool:/usr/bin/ipmitool:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DATA_DIR=/app/data
|
||||
# Required for ipmitool to work in container
|
||||
privileged: true
|
||||
network_mode: host
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,800 @@
|
|||
"""
|
||||
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")
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"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 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,93 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,541 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,745 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,622 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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 }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
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 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"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" }]
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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,
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
#!/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!"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
requests>=2.31.0
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
#!/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}")
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#!/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()
|
||||
|
|
@ -0,0 +1,785 @@
|
|||
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]
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
#!/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!"
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 429 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 254 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 365 B |
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 279 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 278 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 667 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 281 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 267 B |
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 490 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 501 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 395 B |
|
|
@ -0,0 +1,11 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 435 B |
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 540 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 694 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 371 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 394 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 591 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 517 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 830 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 412 B |
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 242 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 406 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 396 B |
|
|
@ -0,0 +1,3 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 288 B |
|
|
@ -1 +0,0 @@
|
|||
# Tests for IPMI Fan Control
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""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
|
||||