Compare commits
No commits in common. "main" and "master" have entirely different histories.
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Data (persist via volume)
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# IPMI Fan Control Configuration
|
||||||
|
|
||||||
|
# Security - CHANGE THIS IN PRODUCTION!
|
||||||
|
SECRET_KEY=your-secure-secret-key-minimum-32-characters-long
|
||||||
|
|
||||||
|
# Fan Control Settings
|
||||||
|
PANIC_TIMEOUT_SECONDS=60
|
||||||
|
PANIC_FAN_SPEED=100
|
||||||
|
|
||||||
|
# Database (default: SQLite)
|
||||||
|
# DATABASE_URL=sqlite:///app/data/ipmi_fan_control.db
|
||||||
|
# For PostgreSQL:
|
||||||
|
# DATABASE_URL=postgresql://user:password@db:5432/ipmi_fan_control
|
||||||
|
|
||||||
|
# Debug mode (disable in production)
|
||||||
|
DEBUG=false
|
||||||
|
|
@ -1,51 +1,49 @@
|
||||||
# Python
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
*.pyo
|
||||||
*.so
|
*.pyd
|
||||||
.Python
|
.Python
|
||||||
build/
|
|
||||||
develop-eggs/
|
# Build outputs
|
||||||
dist/
|
frontend/dist/
|
||||||
downloads/
|
frontend/build/
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
dist/
|
||||||
*.egg
|
build/
|
||||||
|
|
||||||
# Virtual environments
|
# Environment
|
||||||
venv/
|
.env
|
||||||
env/
|
.env.local
|
||||||
ENV/
|
.env.*.local
|
||||||
|
|
||||||
# IDEs
|
# Data and databases
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Application data
|
# OS
|
||||||
data/
|
.DS_Store
|
||||||
*.json
|
Thumbs.db
|
||||||
!data/.gitkeep
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# OS
|
# Testing
|
||||||
.DS_Store
|
coverage/
|
||||||
Thumbs.db
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
# Temp files
|
# Docker
|
||||||
tmp/
|
.docker/
|
||||||
temp/
|
|
||||||
*.tmp
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Multi-stage build for IPMI Fan Control
|
||||||
|
|
||||||
|
# Backend stage
|
||||||
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for ipmitool
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ipmitool \
|
||||||
|
gcc \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
|
||||||
|
# Frontend stage
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy frontend source and build
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ipmitool \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Python dependencies from backend builder
|
||||||
|
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
|
COPY --from=backend-builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
|
# Copy backend application
|
||||||
|
COPY --from=backend-builder /app/backend /app/backend
|
||||||
|
|
||||||
|
# Copy frontend build
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Set Python path to include backend directory
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
|
||||||
|
|
||||||
|
# Start command - use absolute module path
|
||||||
|
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 IPMI Fan Control
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
# IPMI Controller - Persistence Setup
|
|
||||||
|
|
||||||
## Data Persistence
|
|
||||||
|
|
||||||
All configuration and user data is stored in the `data/` directory:
|
|
||||||
- `data/config.json` - All settings, fan curves, IPMI config
|
|
||||||
- `data/users.json` - User accounts and passwords
|
|
||||||
|
|
||||||
**IMPORTANT:** The `data/` directory is committed to git for version control of your settings.
|
|
||||||
|
|
||||||
## Backup Your Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create backup
|
|
||||||
cd ~/ipmi-controller
|
|
||||||
cp -r data data.backup.$(date +%Y%m%d)
|
|
||||||
|
|
||||||
# Or backup to external location
|
|
||||||
cp data/config.json /mnt/backup/ipmi-controller-config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Start on Boot (systemd)
|
|
||||||
|
|
||||||
1. **Create service file:**
|
|
||||||
```bash
|
|
||||||
sudo tee /etc/systemd/system/ipmi-controller.service << 'EOF'
|
|
||||||
[Unit]
|
|
||||||
Description=IPMI Controller
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=devmatrix
|
|
||||||
WorkingDirectory=/home/devmatrix/ipmi-controller
|
|
||||||
ExecStart=/usr/bin/python3 /home/devmatrix/ipmi-controller/web_server.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Enable and start:**
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable ipmi-controller
|
|
||||||
sudo systemctl start ipmi-controller
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Check status:**
|
|
||||||
```bash
|
|
||||||
sudo systemctl status ipmi-controller
|
|
||||||
sudo journalctl -u ipmi-controller -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Deployment (Persistent)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using docker-compose
|
|
||||||
cd ~/ipmi-controller
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Data is persisted in ./data directory
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating Without Losing Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/ipmi-controller
|
|
||||||
|
|
||||||
# Backup first
|
|
||||||
cp -r data data.backup
|
|
||||||
|
|
||||||
# Pull updates
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# Restart
|
|
||||||
sudo systemctl restart ipmi-controller
|
|
||||||
# OR if using docker:
|
|
||||||
docker-compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Gets Persisted
|
|
||||||
|
|
||||||
✅ IPMI connection settings
|
|
||||||
✅ HTTP sensor configuration
|
|
||||||
✅ Fan curves (Balanced, Silent, Performance, etc.)
|
|
||||||
✅ User accounts and passwords
|
|
||||||
✅ Theme preference (dark/light)
|
|
||||||
✅ Fan groups and custom names
|
|
||||||
✅ All control settings (poll interval, panic temps, etc.)
|
|
||||||
|
|
||||||
## Migration to New Server
|
|
||||||
|
|
||||||
1. Copy `data/config.json` and `data/users.json` to new server
|
|
||||||
2. Install ipmitool: `sudo apt-get install ipmitool`
|
|
||||||
3. Install Python deps: `pip install -r requirements.txt`
|
|
||||||
4. Start server: `python3 web_server.py`
|
|
||||||
501
README.md
|
|
@ -1,406 +1,237 @@
|
||||||
# IPMI Controller
|
# IPMI Fan Control
|
||||||
|
|
||||||
Advanced web-based fan control for Dell servers with IPMI support. Automatically adjust fan speeds based on temperature readings from IPMI sensors and optional HTTP lm-sensors endpoint.
|
A modern web-based application for controlling fan speeds on Dell T710 and compatible servers using IPMI. Features a clean web UI, automatic fan curves, panic mode for safety, and support for multiple servers.
|
||||||
|
|
||||||
**Version:** 1.0.0
|

|
||||||
**Author:** ImpulsiveFPS
|
|
||||||
**License:** MIT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🌡️ **Temperature Monitoring** - Real-time CPU, inlet, exhaust, and PCIe temperature monitoring
|
- 🖥️ **Multiple Server Support** - Manage multiple servers from a single interface
|
||||||
- 🌀 **Automatic Fan Control** - Dynamic fan speed adjustment based on customizable temperature curves
|
- 🌡️ **Temperature-Based Fan Curves** - Automatically adjust fan speeds based on CPU temperatures
|
||||||
- 📊 **Fan Groups** - Group fans together for unified control
|
- ⚡ **Panic Mode** - Automatically sets fans to 100% if sensor data is lost
|
||||||
- 📈 **Custom Curves** - Create custom fan curves and assign them to specific fan groups
|
- 🎛️ **Manual Fan Control** - Direct control over individual fans or all fans at once
|
||||||
- 🖥️ **HTTP Sensors** - Optional integration with lm-sensors for additional temperature data
|
- 📊 **Real-time Monitoring** - View temperatures, fan speeds, and power consumption
|
||||||
- 🎨 **Dark/Light Theme** - Choose your preferred visual style
|
- 🔒 **Secure Authentication** - JWT-based authentication with encrypted passwords
|
||||||
- 🔒 **Secure** - Built-in authentication and session management
|
- 🐳 **Docker Support** - Easy deployment with Docker Compose
|
||||||
- 🚀 **Auto-Start** - Automatically resumes operation after system restart
|
- 🔄 **Persistent Storage** - Settings and credentials survive container restarts
|
||||||
|
|
||||||
---
|
## Supported Servers
|
||||||
|
|
||||||
## Table of Contents
|
- Dell PowerEdge T710 (tested)
|
||||||
|
- Dell PowerEdge R710/R720/R730 (should work)
|
||||||
|
- Dell PowerEdge R810/R820/R910/R920 (should work)
|
||||||
|
- HPE servers with iLO (partial support)
|
||||||
|
|
||||||
- [Prerequisites](#prerequisites)
|
## Quick Start
|
||||||
- [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
|
||||||
|
|
||||||
## Prerequisites
|
- Docker and Docker Compose installed
|
||||||
|
- IPMI enabled on your server(s) with network access
|
||||||
|
|
||||||
### Hardware Requirements
|
### Installation
|
||||||
|
|
||||||
- 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
|
```bash
|
||||||
# Ubuntu/Debian
|
git clone https://git.lemonlink.eu/impulsivefps/ipmi-fan-control.git
|
||||||
sudo apt update
|
cd ipmi-fan-control
|
||||||
sudo apt install ipmitool
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
ipmitool -V
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
2. Copy the example environment file and edit it:
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Clone the Repository
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ImpulsiveFPS/IPMI-Controller.git
|
cp .env.example .env
|
||||||
cd IPMI-Controller
|
# Edit .env and set a secure SECRET_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Install Python Dependencies
|
3. Create the data directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
mkdir -p data
|
||||||
```
|
```
|
||||||
|
|
||||||
Required packages:
|
4. Start the application:
|
||||||
- fastapi
|
|
||||||
- uvicorn
|
|
||||||
- pydantic
|
|
||||||
- requests
|
|
||||||
|
|
||||||
### 3. Start the Application
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 web_server.py
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The web interface will be available at `http://localhost:8000`
|
5. Access the web interface at `http://your-server-ip:8000`
|
||||||
|
|
||||||
### 4. (Optional) Systemd Service
|
6. Complete the setup wizard to create your admin account
|
||||||
|
|
||||||
To run the controller as a system service:
|
### First Time Setup
|
||||||
|
|
||||||
```bash
|
1. When you first access the web UI, you'll be guided through a setup wizard
|
||||||
sudo cp ipmi-controller.service /etc/systemd/system/
|
2. Create an administrator account
|
||||||
sudo systemctl daemon-reload
|
3. Add your first server by providing:
|
||||||
sudo systemctl enable ipmi-controller
|
- Server name
|
||||||
sudo systemctl start ipmi-controller
|
- IP address/hostname
|
||||||
```
|
- IPMI username and password
|
||||||
|
- Vendor (Dell, HPE, etc.)
|
||||||
|
|
||||||
---
|
## Usage
|
||||||
|
|
||||||
## IPMI Setup
|
### Manual Fan Control
|
||||||
|
|
||||||
### Step 1: Configure iDRAC/IPMI Network Settings
|
1. Go to a server's detail page
|
||||||
|
2. Click on the "Fan Control" tab
|
||||||
|
3. Enable "Manual Fan Control"
|
||||||
|
4. Use the slider to set the desired fan speed
|
||||||
|
5. Click "Apply" to send the command
|
||||||
|
|
||||||
1. Boot into your Dell server's BIOS/F2 setup
|
### Automatic Fan Curves
|
||||||
2. Navigate to **iDRAC Settings** → **Network**
|
|
||||||
3. Configure a static IP address for iDRAC (e.g., `192.168.5.191`)
|
|
||||||
4. Save and exit
|
|
||||||
|
|
||||||
Alternatively, configure via iDRAC web interface:
|
1. Go to the "Fan Curves" page for your server
|
||||||
1. Access iDRAC at its current IP
|
2. Create a new fan curve with temperature/speed points
|
||||||
2. Go to **iDRAC Settings** → **Network** → **IPV4 Settings**
|
3. Select the curve and click "Start Auto Control"
|
||||||
3. Set static IP, subnet mask, and gateway
|
4. The system will automatically adjust fan speeds based on temperatures
|
||||||
4. Apply changes
|
|
||||||
|
|
||||||
### Step 2: Create IPMI User
|
### Panic Mode
|
||||||
|
|
||||||
#### Method 1: Via iDRAC Web Interface (Recommended)
|
Panic mode is enabled by default. If the application loses connection to a server and cannot retrieve sensor data for the configured timeout period (default: 60 seconds), it will automatically set all fans to 100% speed for safety.
|
||||||
|
|
||||||
1. Log into iDRAC web interface
|
## IPMI Commands Used
|
||||||
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
|
|
||||||
|
|
||||||
#### Method 2: Via ipmitool (Local Access Required)
|
This application uses the following IPMI raw commands:
|
||||||
|
|
||||||
```bash
|
### Dell Servers
|
||||||
# List current users
|
|
||||||
sudo ipmitool user list 1
|
|
||||||
|
|
||||||
# Create new user (ID 3)
|
- **Enable Manual Control**: `raw 0x30 0x30 0x01 0x00`
|
||||||
sudo ipmitool user set name 3 root
|
- **Disable Manual Control**: `raw 0x30 0x30 0x01 0x01`
|
||||||
sudo ipmitool user set password 3 YOUR_PASSWORD
|
- **Set Fan Speed**: `raw 0x30 0x30 0x02 <fan_id> <speed_hex>`
|
||||||
sudo ipmitool channel setaccess 1 3 callin=on ipmi=on link=on privilege=4
|
- **Get 3rd Party PCIe Status**: `raw 0x30 0xce 0x01 0x16 0x05 0x00 0x00 0x00`
|
||||||
sudo ipmitool user enable 3
|
- **Enable 3rd Party PCIe Response**: `raw 0x30 0xce 0x00 0x16 0x05 0x00 0x00 0x00 0x05 0x00 0x00 0x00 0x00`
|
||||||
|
- **Disable 3rd Party PCIe Response**: `raw 0x30 0xce 0x00 0x16 0x05 0x00 0x00 0x00 0x05 0x00 0x01 0x00 0x00`
|
||||||
|
|
||||||
# Verify
|
### Standard IPMI
|
||||||
sudo ipmitool user list 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable IPMI over LAN
|
- **Get Temperatures**: `sdr type temperature`
|
||||||
|
- **Get All Sensors**: `sdr elist full`
|
||||||
|
- **Get Power Supply Status**: `sdr type 'Power Supply'`
|
||||||
|
- **Dell Power Monitor**: `delloem powermonitor`
|
||||||
|
|
||||||
```bash
|
## Fan Mapping
|
||||||
# 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
|
|
||||||
|
|
||||||
# Verify settings
|
For Dell servers, the fan mapping is:
|
||||||
sudo ipmitool lan print 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Test IPMI Connection
|
| IPMI ID | Physical Fan |
|
||||||
|
|---------|--------------|
|
||||||
From another machine on the network:
|
| 0x00 | Fan 1 |
|
||||||
|
| 0x01 | Fan 2 |
|
||||||
```bash
|
| 0x02 | Fan 3 |
|
||||||
ipmitool -I lanplus -H 192.168.5.191 -U root -P YOUR_PASSWORD chassis status
|
| 0x03 | Fan 4 |
|
||||||
```
|
| 0x04 | Fan 5 |
|
||||||
|
| 0x05 | Fan 6 |
|
||||||
If successful, you'll see server power status and other information.
|
| 0x06 | Fan 7 |
|
||||||
|
| 0xff | All Fans |
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
## Configuration
|
||||||
|
|
||||||
### Fan Curves
|
### Environment Variables
|
||||||
|
|
||||||
Fan curves define how fan speed responds to temperature:
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SECRET_KEY` | (required) | Secret key for JWT tokens |
|
||||||
|
| `DATA_DIR` | /app/data | Directory for persistent data |
|
||||||
|
| `PANIC_TIMEOUT_SECONDS` | 60 | Seconds before panic mode activates |
|
||||||
|
| `PANIC_FAN_SPEED` | 100 | Fan speed during panic mode (%) |
|
||||||
|
| `DATABASE_URL` | sqlite:///app/data/... | Database connection string |
|
||||||
|
| `DEBUG` | false | Enable debug mode |
|
||||||
|
|
||||||
1. Go to **Curves** tab
|
### Docker Compose
|
||||||
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
|
|
||||||
|
|
||||||
### Fan Groups
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
Groups allow unified control of multiple fans:
|
services:
|
||||||
|
ipmi-fan-control:
|
||||||
|
image: ipmi-fan-control:latest
|
||||||
|
container_name: ipmi-fan-control
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=your-secure-secret-key
|
||||||
|
- PANIC_TIMEOUT_SECONDS=60
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
1. Go to **Fan Groups** tab
|
## Building from Source
|
||||||
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
|
|
||||||
|
|
||||||
### Quick Controls
|
### Backend
|
||||||
|
|
||||||
- **Start Auto:** Enable automatic fan control
|
```bash
|
||||||
- **Stop Auto:** Return to manual/BIOS control
|
cd backend
|
||||||
- **Manual Speed Slider:** Set all fans to specific speed
|
pip install -r requirements.txt
|
||||||
- **Identify Fan:** Flash individual fan to 100% for identification
|
python -m uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
---
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The application consists of:
|
||||||
|
|
||||||
|
- **Backend**: Python FastAPI with SQLAlchemy ORM
|
||||||
|
- **Frontend**: React with TypeScript, Material-UI, and Recharts
|
||||||
|
- **Database**: SQLite (default) or PostgreSQL
|
||||||
|
- **IPMI**: Direct integration with ipmitool
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Change the default SECRET_KEY** in production
|
||||||
|
2. Use HTTPS when accessing over the internet
|
||||||
|
3. Place behind a reverse proxy (nginx, traefik) for SSL termination
|
||||||
|
4. Use strong passwords for the admin account
|
||||||
|
5. Server passwords are encrypted at rest using Fernet encryption
|
||||||
|
6. Regularly update the Docker image for security patches
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Issues
|
### Cannot connect to server
|
||||||
|
|
||||||
**"Not connected" status:**
|
1. Verify IPMI is enabled in the server's BIOS/iDRAC settings
|
||||||
- Verify IPMI IP address is correct
|
2. Check network connectivity: `ping <server-ip>`
|
||||||
- Check network connectivity: `ping 192.168.5.191`
|
3. Test IPMI manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
|
||||||
- Test with ipmitool: `ipmitool -I lanplus -H 192.168.5.191 -U root chassis status`
|
4. Check firewall rules allow IPMI traffic (port 623)
|
||||||
- Ensure IPMI user has Administrator privileges
|
|
||||||
- Verify IPMI over LAN is enabled
|
|
||||||
|
|
||||||
**"Connection timeout":**
|
### Fan control not working
|
||||||
- Check firewall rules on IPMI network
|
|
||||||
- Verify port 623 is open
|
|
||||||
- Try increasing timeout in settings
|
|
||||||
|
|
||||||
### Fan Control Not Working
|
1. Ensure manual fan control is enabled first
|
||||||
|
2. Check that the server supports the IPMI raw commands
|
||||||
|
3. Verify you have admin/root IPMI privileges
|
||||||
|
4. Some servers require 3rd party PCIe card response to be disabled
|
||||||
|
|
||||||
**Fans not responding to speed changes:**
|
### Container won't start
|
||||||
- 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
|
|
||||||
|
|
||||||
**"Manual fan control not supported":**
|
1. Check logs: `docker-compose logs -f`
|
||||||
- Some server models don't support external fan control
|
2. Verify data directory has correct permissions
|
||||||
- Check Dell documentation for your specific model
|
3. Ensure port 8000 is not already in use
|
||||||
- Try updating iDRAC firmware
|
|
||||||
|
|
||||||
### HTTP Sensor Issues
|
## License
|
||||||
|
|
||||||
**"HTTP Sensor not working":**
|
MIT License - See LICENSE file for details
|
||||||
- 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
|
## Support
|
||||||
|
|
||||||
- 🐛 **Report Bugs:** https://github.com/ImpulsiveFPS/IPMI-Controller/issues
|
For issues and feature requests, please use the GitHub issue tracker.
|
||||||
- 📁 **GitHub Repo:** https://github.com/ImpulsiveFPS/IPMI-Controller
|
|
||||||
- ☕ **Support on Ko-fi:** https://ko-fi.com/impulsivefps
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Built with:
|
- Dell for the IPMI command documentation
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/) - Web framework
|
- The ipmitool project
|
||||||
- [Heroicons](https://heroicons.com/) - Icons
|
- FastAPI and React communities
|
||||||
- [Lucide](https://lucide.dev/) - Additional icons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**IPMI Controller v1.0.0 - Built by ImpulsiveFPS**
|
|
||||||
|
|
|
||||||
183
SETUP.md
|
|
@ -1,183 +0,0 @@
|
||||||
# IPMI Controller - Setup Guide
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Dell server with IPMI (iDRAC) enabled
|
|
||||||
- Linux host (Ubuntu/Debian recommended)
|
|
||||||
- Python 3.10+
|
|
||||||
- ipmitool
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Install IPMI Controller
|
|
||||||
|
|
||||||
On your management server (where you run the controller):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/ipmi-controller.git
|
|
||||||
cd ipmi-controller
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install ipmitool
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y ipmitool
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run the Controller
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 web_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://your-server:8000` in browser.
|
|
||||||
|
|
||||||
## Initial Setup
|
|
||||||
|
|
||||||
1. **Complete the Setup Wizard:**
|
|
||||||
- Create admin account
|
|
||||||
- Enter IPMI credentials
|
|
||||||
- IP: Your Dell server's IPMI IP
|
|
||||||
- Username: Usually "root"
|
|
||||||
- Password: Your IPMI password
|
|
||||||
- Port: 623 (default)
|
|
||||||
|
|
||||||
2. **Login** with your new admin credentials
|
|
||||||
|
|
||||||
## lm-sensors HTTP Server (Optional but Recommended)
|
|
||||||
|
|
||||||
For better temperature monitoring (including PCIe cards), set up lm-sensors on your Dell server:
|
|
||||||
|
|
||||||
### Option A: Automated Setup
|
|
||||||
|
|
||||||
On your **Dell/Proxmox server** (not the controller):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download and run setup script
|
|
||||||
curl -O https://raw.githubusercontent.com/yourusername/ipmi-controller/main/setup-sensors-server.sh
|
|
||||||
chmod +x setup-sensors-server.sh
|
|
||||||
sudo ./setup-sensors-server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Manual Setup
|
|
||||||
|
|
||||||
1. **Install lm-sensors:**
|
|
||||||
```bash
|
|
||||||
sudo apt-get install -y lm-sensors netcat-openbsd
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Detect sensors:**
|
|
||||||
```bash
|
|
||||||
sudo sensors-detect --auto
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test sensors:**
|
|
||||||
```bash
|
|
||||||
sensors
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Create HTTP server script** (`/usr/local/bin/sensors-http-server.sh`):
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
PORT=${1:-8888}
|
|
||||||
while true; do
|
|
||||||
{
|
|
||||||
echo -e "HTTP/1.1 200 OK\r"
|
|
||||||
echo -e "Content-Type: text/plain\r"
|
|
||||||
echo -e "Access-Control-Allow-Origin: *\r"
|
|
||||||
echo -e "\r"
|
|
||||||
sensors -u 2>/dev/null || echo "Error"
|
|
||||||
} | nc -l -p "$PORT" -q 1
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Make executable:**
|
|
||||||
```bash
|
|
||||||
chmod +x /usr/local/bin/sensors-http-server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create systemd service** (`/etc/systemd/system/sensors-http.service`):
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=lm-sensors HTTP Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/sensors-http-server.sh 8888
|
|
||||||
Restart=always
|
|
||||||
User=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Enable and start:**
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable sensors-http
|
|
||||||
sudo systemctl start sensors-http
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Test:**
|
|
||||||
```bash
|
|
||||||
curl http://$(hostname -I | awk '{print $1}'):8888
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configure IPMI Controller
|
|
||||||
|
|
||||||
1. Go to **Settings** → **HTTP** tab
|
|
||||||
2. Enable "HTTP Sensor"
|
|
||||||
3. Enter URL: `http://your-dell-server-ip:8888`
|
|
||||||
4. Save
|
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t ipmi-controller .
|
|
||||||
docker run -d \
|
|
||||||
-p 8000:8000 \
|
|
||||||
-v $(pwd)/data:/app/data \
|
|
||||||
--name ipmi-controller \
|
|
||||||
ipmi-controller
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### IPMI Connection Failed
|
|
||||||
- Verify IPMI IP is correct
|
|
||||||
- Check IPMI username/password
|
|
||||||
- Ensure IPMI is enabled in BIOS/iDRAC
|
|
||||||
- Test manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
|
|
||||||
|
|
||||||
### No Temperature Data
|
|
||||||
- Check if lm-sensors is installed on Dell server
|
|
||||||
- Run `sensors` to verify it works
|
|
||||||
- Check HTTP endpoint: `curl http://dell-ip:8888`
|
|
||||||
|
|
||||||
### Service Won't Start
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
sudo journalctl -u sensors-http -f
|
|
||||||
|
|
||||||
# Check if port is in use
|
|
||||||
sudo netstat -tlnp | grep 8888
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- Change default password after first login
|
|
||||||
- Use HTTPS/reverse proxy for production
|
|
||||||
- Firewall port 8000 to internal network only
|
|
||||||
- HTTP sensor endpoint is read-only
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ipmi-controller
|
|
||||||
git pull
|
|
||||||
pip install -r requirements.txt
|
|
||||||
# Restart the service
|
|
||||||
```
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# IPMI Fan Control Backend
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Authentication and security utilities."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Union
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
from 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)
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Simple in-memory cache for expensive operations."""
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional, Callable
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
"""Thread-safe in-memory cache with TTL."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: dict = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""Get value from cache if not expired."""
|
||||||
|
with self._lock:
|
||||||
|
if key not in self._cache:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = self._cache[key]
|
||||||
|
if entry['expires'] < time.time():
|
||||||
|
del self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return entry['value']
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl: int = 60):
|
||||||
|
"""Set value in cache with TTL (seconds)."""
|
||||||
|
with self._lock:
|
||||||
|
self._cache[key] = {
|
||||||
|
'value': value,
|
||||||
|
'expires': time.time() + ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
"""Delete key from cache."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all cache."""
|
||||||
|
with self._lock:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
def get_or_set(self, key: str, factory: Callable, ttl: int = 60) -> Any:
|
||||||
|
"""Get from cache or call factory and cache result."""
|
||||||
|
value = self.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
value = factory()
|
||||||
|
self.set(key, value, ttl)
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Global cache instance
|
||||||
|
cache = Cache()
|
||||||
|
|
||||||
|
def make_key(*args, **kwargs) -> str:
|
||||||
|
"""Create cache key from arguments."""
|
||||||
|
key_data = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, default=str)
|
||||||
|
return hashlib.md5(key_data.encode()).hexdigest()
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Application configuration."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings."""
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY: str = Field(default="change-me-in-production", description="Secret key for JWT")
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = Field(default=f"sqlite:///{DATA_DIR}/ipmi_fan_control.db")
|
||||||
|
|
||||||
|
# IPMI Settings
|
||||||
|
IPMITOOL_PATH: str = Field(default="ipmitool", description="Path to ipmitool binary")
|
||||||
|
PANIC_TIMEOUT_SECONDS: int = Field(default=60, description="Seconds without sensor data before panic mode")
|
||||||
|
PANIC_FAN_SPEED: int = Field(default=100, description="Fan speed during panic mode")
|
||||||
|
|
||||||
|
# Fan Control Settings
|
||||||
|
DEFAULT_FAN_CURVE: list = Field(default=[
|
||||||
|
{"temp": 30, "speed": 10},
|
||||||
|
{"temp": 40, "speed": 20},
|
||||||
|
{"temp": 50, "speed": 35},
|
||||||
|
{"temp": 60, "speed": 50},
|
||||||
|
{"temp": 70, "speed": 70},
|
||||||
|
{"temp": 80, "speed": 100},
|
||||||
|
])
|
||||||
|
|
||||||
|
# App Settings
|
||||||
|
APP_NAME: str = "IPMI Fan Control"
|
||||||
|
DEBUG: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""Database models and session management."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
create_engine, Column, Integer, String, Boolean,
|
||||||
|
DateTime, Float, ForeignKey, Text, event
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker, relationship, Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from backend.config import settings, DATA_DIR
|
||||||
|
|
||||||
|
# Create engine
|
||||||
|
if settings.DATABASE_URL.startswith("sqlite"):
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
# Database Models
|
||||||
|
class User(Base):
|
||||||
|
"""Admin user model."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(Base):
|
||||||
|
"""IPMI Server model."""
|
||||||
|
__tablename__ = "servers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# IPMI Settings
|
||||||
|
ipmi_host = Column(String(100), nullable=False) # IPMI IP address
|
||||||
|
ipmi_port = Column(Integer, default=623)
|
||||||
|
ipmi_username = Column(String(100), nullable=False)
|
||||||
|
ipmi_encrypted_password = Column(String(255), nullable=False) # Encrypted password
|
||||||
|
|
||||||
|
# SSH Settings (for lm-sensors)
|
||||||
|
ssh_host = Column(String(100), nullable=True) # SSH host (can be same as IPMI or different)
|
||||||
|
ssh_port = Column(Integer, default=22)
|
||||||
|
ssh_username = Column(String(100), nullable=True)
|
||||||
|
ssh_encrypted_password = Column(String(255), nullable=True) # Encrypted password or use key
|
||||||
|
ssh_key_file = Column(String(255), nullable=True) # Path to SSH key file
|
||||||
|
use_ssh = Column(Boolean, default=False) # Whether to use SSH for sensor data
|
||||||
|
|
||||||
|
# Server type (dell, hpe, etc.)
|
||||||
|
vendor = Column(String(50), default="dell")
|
||||||
|
|
||||||
|
# Fan control settings
|
||||||
|
manual_control_enabled = Column(Boolean, default=False)
|
||||||
|
third_party_pcie_response = Column(Boolean, default=True)
|
||||||
|
fan_curve_data = Column(Text, nullable=True) # JSON string
|
||||||
|
auto_control_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Panic mode settings
|
||||||
|
panic_mode_enabled = Column(Boolean, default=True)
|
||||||
|
panic_timeout_seconds = Column(Integer, default=60)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
last_seen = Column(DateTime, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
sensor_data = relationship("SensorData", back_populates="server", cascade="all, delete-orphan")
|
||||||
|
fan_data = relationship("FanData", back_populates="server", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurve(Base):
|
||||||
|
"""Fan curve configuration."""
|
||||||
|
__tablename__ = "fan_curves"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||||
|
name = Column(String(100), default="Default")
|
||||||
|
curve_data = Column(Text, nullable=False) # JSON array of {temp, speed} points
|
||||||
|
sensor_source = Column(String(50), default="cpu") # cpu, inlet, exhaust, etc.
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorData(Base):
|
||||||
|
"""Historical sensor data."""
|
||||||
|
__tablename__ = "sensor_data"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False, index=True)
|
||||||
|
sensor_name = Column(String(100), nullable=False, index=True)
|
||||||
|
sensor_type = Column(String(50), nullable=False, index=True) # temperature, voltage, fan, power
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
unit = Column(String(20), nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
server = relationship("Server", back_populates="sensor_data")
|
||||||
|
|
||||||
|
|
||||||
|
class FanData(Base):
|
||||||
|
"""Historical fan speed data."""
|
||||||
|
__tablename__ = "fan_data"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False, index=True)
|
||||||
|
fan_number = Column(Integer, nullable=False)
|
||||||
|
fan_id = Column(String(20), nullable=False) # IPMI fan ID (0x00, 0x01, etc.)
|
||||||
|
speed_rpm = Column(Integer, nullable=True)
|
||||||
|
speed_percent = Column(Integer, nullable=True)
|
||||||
|
is_manual = Column(Boolean, default=False)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
server = relationship("Server", back_populates="fan_data")
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLog(Base):
|
||||||
|
"""System event logs."""
|
||||||
|
__tablename__ = "system_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=True, index=True)
|
||||||
|
event_type = Column(String(50), nullable=False, index=True) # panic, fan_change, error, warning, info
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
details = Column(Text, nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(Base):
|
||||||
|
"""Application settings storage."""
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String(100), unique=True, nullable=False)
|
||||||
|
value = Column(Text, nullable=True)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Session:
|
||||||
|
"""Get database session."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Initialize database tables."""
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create default admin if no users exist
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Check if setup is complete
|
||||||
|
setup_complete = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first()
|
||||||
|
if not setup_complete:
|
||||||
|
AppSettings(key="setup_complete", value="false")
|
||||||
|
db.add(AppSettings(key="setup_complete", value="false"))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def is_setup_complete(db: Session) -> bool:
|
||||||
|
"""Check if initial setup is complete."""
|
||||||
|
setting = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first()
|
||||||
|
if setting:
|
||||||
|
return setting.value == "true"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_setup_complete(db: Session, complete: bool = True):
|
||||||
|
"""Mark setup as complete."""
|
||||||
|
setting = db.query(AppSettings).filter(AppSettings.key == "setup_complete").first()
|
||||||
|
if setting:
|
||||||
|
setting.value = "true" if complete else "false"
|
||||||
|
else:
|
||||||
|
setting = AppSettings(key="setup_complete", value="true" if complete else "false")
|
||||||
|
db.add(setting)
|
||||||
|
db.commit()
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
"""Fan control logic including curves and automatic control."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import (
|
||||||
|
Server, FanCurve, SensorData, FanData, SystemLog,
|
||||||
|
get_db, SessionLocal
|
||||||
|
)
|
||||||
|
from backend.ipmi_client import IPMIClient, TemperatureReading
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlState(Enum):
|
||||||
|
"""Fan control state."""
|
||||||
|
AUTO = "auto"
|
||||||
|
MANUAL = "manual"
|
||||||
|
PANIC = "panic"
|
||||||
|
OFF = "off"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FanCurvePoint:
|
||||||
|
"""Single point on a fan curve."""
|
||||||
|
temp: float
|
||||||
|
speed: int
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurveManager:
|
||||||
|
"""Manages fan curve calculations."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_curve(curve_data: str) -> List[FanCurvePoint]:
|
||||||
|
"""Parse fan curve from JSON string."""
|
||||||
|
try:
|
||||||
|
data = json.loads(curve_data)
|
||||||
|
return [FanCurvePoint(p["temp"], p["speed"]) for p in data]
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"Failed to parse fan curve: {e}")
|
||||||
|
# Return default curve
|
||||||
|
return [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(40, 20),
|
||||||
|
FanCurvePoint(50, 35),
|
||||||
|
FanCurvePoint(60, 50),
|
||||||
|
FanCurvePoint(70, 70),
|
||||||
|
FanCurvePoint(80, 100),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_curve(points: List[FanCurvePoint]) -> str:
|
||||||
|
"""Serialize fan curve to JSON string."""
|
||||||
|
return json.dumps([{"temp": p.temp, "speed": p.speed} for p in points])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_speed(curve: List[FanCurvePoint], temperature: float) -> int:
|
||||||
|
"""Calculate fan speed for a given temperature using linear interpolation."""
|
||||||
|
if not curve:
|
||||||
|
return 50 # Default to 50% if no curve
|
||||||
|
|
||||||
|
# Sort by temperature
|
||||||
|
sorted_curve = sorted(curve, key=lambda p: p.temp)
|
||||||
|
|
||||||
|
# Below minimum temp
|
||||||
|
if temperature <= sorted_curve[0].temp:
|
||||||
|
return sorted_curve[0].speed
|
||||||
|
|
||||||
|
# Above maximum temp
|
||||||
|
if temperature >= sorted_curve[-1].temp:
|
||||||
|
return sorted_curve[-1].speed
|
||||||
|
|
||||||
|
# Find surrounding points
|
||||||
|
for i in range(len(sorted_curve) - 1):
|
||||||
|
p1 = sorted_curve[i]
|
||||||
|
p2 = sorted_curve[i + 1]
|
||||||
|
|
||||||
|
if p1.temp <= temperature <= p2.temp:
|
||||||
|
# Linear interpolation
|
||||||
|
if p2.temp == p1.temp:
|
||||||
|
return p1.speed
|
||||||
|
|
||||||
|
ratio = (temperature - p1.temp) / (p2.temp - p1.temp)
|
||||||
|
speed = p1.speed + ratio * (p2.speed - p1.speed)
|
||||||
|
return int(round(speed))
|
||||||
|
|
||||||
|
return sorted_curve[-1].speed
|
||||||
|
|
||||||
|
|
||||||
|
class FanController:
|
||||||
|
"""Main fan controller for managing server fans."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.curve_manager = FanCurveManager()
|
||||||
|
self.running = False
|
||||||
|
self._tasks: Dict[int, asyncio.Task] = {}
|
||||||
|
self._last_sensor_data: Dict[int, datetime] = {}
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the fan controller service."""
|
||||||
|
self.running = True
|
||||||
|
logger.info("Fan controller started")
|
||||||
|
|
||||||
|
# Load all servers with auto-control enabled
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
servers = db.query(Server).filter(
|
||||||
|
Server.auto_control_enabled == True,
|
||||||
|
Server.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
await self.start_server_control(server.id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop all fan control tasks."""
|
||||||
|
self.running = False
|
||||||
|
for task in self._tasks.values():
|
||||||
|
task.cancel()
|
||||||
|
self._tasks.clear()
|
||||||
|
logger.info("Fan controller stopped")
|
||||||
|
|
||||||
|
async def start_server_control(self, server_id: int):
|
||||||
|
"""Start automatic control for a server."""
|
||||||
|
if server_id in self._tasks:
|
||||||
|
self._tasks[server_id].cancel()
|
||||||
|
|
||||||
|
task = asyncio.create_task(self._control_loop(server_id))
|
||||||
|
self._tasks[server_id] = task
|
||||||
|
logger.info(f"Started fan control for server {server_id}")
|
||||||
|
|
||||||
|
async def stop_server_control(self, server_id: int):
|
||||||
|
"""Stop automatic control for a server."""
|
||||||
|
if server_id in self._tasks:
|
||||||
|
self._tasks[server_id].cancel()
|
||||||
|
del self._tasks[server_id]
|
||||||
|
logger.info(f"Stopped fan control for server {server_id}")
|
||||||
|
|
||||||
|
async def _control_loop(self, server_id: int):
|
||||||
|
"""Main control loop for a server."""
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self._control_iteration(server_id)
|
||||||
|
await asyncio.sleep(5) # 5 second interval
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Control loop error for server {server_id}: {e}")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
async def _control_iteration(self, server_id: int):
|
||||||
|
"""Single control iteration for a server."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
server = db.query(Server).filter(Server.id == server_id).first()
|
||||||
|
if not server or not server.is_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
from backend.auth import decrypt_password
|
||||||
|
|
||||||
|
# Create IPMI client
|
||||||
|
client = IPMIClient(
|
||||||
|
host=server.ipmi_host,
|
||||||
|
username=server.ipmi_username,
|
||||||
|
password=decrypt_password(server.ipmi_encrypted_password),
|
||||||
|
port=server.ipmi_port,
|
||||||
|
vendor=server.vendor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection with timeout
|
||||||
|
if not await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.test_connection),
|
||||||
|
timeout=10.0
|
||||||
|
):
|
||||||
|
logger.warning(f"Cannot connect to server {server.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get sensor data with timeout
|
||||||
|
temps = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.get_temperatures),
|
||||||
|
timeout=15.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last sensor data time
|
||||||
|
self._last_sensor_data[server_id] = datetime.utcnow()
|
||||||
|
server.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
# Calculate and set fan speed if auto control is enabled
|
||||||
|
if server.auto_control_enabled:
|
||||||
|
await self._apply_fan_curve(db, server, client, temps)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"Control iteration timeout for server {server_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Control iteration error for server {server_id}: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def _apply_fan_curve(self, db: Session, server: Server,
|
||||||
|
client: IPMIClient, temps: List[TemperatureReading]):
|
||||||
|
"""Apply fan curve based on temperatures."""
|
||||||
|
if not temps:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get active fan curve
|
||||||
|
curve_data = server.fan_curve_data
|
||||||
|
if not curve_data:
|
||||||
|
curve = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(40, 20),
|
||||||
|
FanCurvePoint(50, 35),
|
||||||
|
FanCurvePoint(60, 50),
|
||||||
|
FanCurvePoint(70, 70),
|
||||||
|
FanCurvePoint(80, 100),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
curve = self.curve_manager.parse_curve(curve_data)
|
||||||
|
|
||||||
|
# Find the highest CPU temperature
|
||||||
|
cpu_temps = [t for t in temps if t.location.startswith("cpu")]
|
||||||
|
if cpu_temps:
|
||||||
|
max_temp = max(t.value for t in cpu_temps)
|
||||||
|
else:
|
||||||
|
max_temp = max(t.value for t in temps)
|
||||||
|
|
||||||
|
# Calculate target speed
|
||||||
|
target_speed = self.curve_manager.calculate_speed(curve, max_temp)
|
||||||
|
|
||||||
|
# Enable manual control if not already
|
||||||
|
if not server.manual_control_enabled:
|
||||||
|
if await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.enable_manual_fan_control),
|
||||||
|
timeout=10.0
|
||||||
|
):
|
||||||
|
server.manual_control_enabled = True
|
||||||
|
logger.info(f"Enabled manual fan control for {server.name}")
|
||||||
|
|
||||||
|
# Set fan speed
|
||||||
|
if await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.set_all_fans_speed, target_speed),
|
||||||
|
timeout=10.0
|
||||||
|
):
|
||||||
|
logger.info(f"Set {server.name} fans to {target_speed}% (temp: {max_temp}°C)")
|
||||||
|
|
||||||
|
def get_controller_status(self, server_id: int) -> Dict[str, Any]:
|
||||||
|
"""Get current controller status for a server."""
|
||||||
|
is_running = server_id in self._tasks
|
||||||
|
last_seen = self._last_sensor_data.get(server_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_running": is_running,
|
||||||
|
"last_sensor_data": last_seen.isoformat() if last_seen else None,
|
||||||
|
"state": ControlState.AUTO.value if is_running else ControlState.OFF.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SensorCollector:
|
||||||
|
"""High-performance background sensor data collector.
|
||||||
|
|
||||||
|
- Collects from all servers in parallel using thread pool
|
||||||
|
- Times out slow operations to prevent hanging
|
||||||
|
- Cleans up old database records periodically
|
||||||
|
- Updates cache for fast web UI access
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_workers: int = 4):
|
||||||
|
self.running = False
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._collection_interval = 30 # seconds - IPMI is slow, need more time
|
||||||
|
self._cleanup_interval = 3600 # 1 hour
|
||||||
|
self._cache = None
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||||
|
self._last_cleanup = datetime.utcnow()
|
||||||
|
self._first_collection_done = False
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the sensor collector."""
|
||||||
|
self.running = True
|
||||||
|
self._task = asyncio.create_task(self._collection_loop())
|
||||||
|
logger.info("Sensor collector started")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the sensor collector."""
|
||||||
|
self.running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
self._executor.shutdown(wait=False)
|
||||||
|
logger.info("Sensor collector stopped")
|
||||||
|
|
||||||
|
async def _collection_loop(self):
|
||||||
|
"""Main collection loop."""
|
||||||
|
# Initial collection immediately on startup
|
||||||
|
try:
|
||||||
|
logger.info("Performing initial sensor collection...")
|
||||||
|
await self._collect_all_servers()
|
||||||
|
self._first_collection_done = True
|
||||||
|
logger.info("Initial sensor collection complete")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Initial collection error: {e}")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
await self._collect_all_servers()
|
||||||
|
|
||||||
|
# Periodic database cleanup
|
||||||
|
if (datetime.utcnow() - self._last_cleanup).total_seconds() > self._cleanup_interval:
|
||||||
|
await self._cleanup_old_data()
|
||||||
|
|
||||||
|
# Calculate sleep time to maintain interval
|
||||||
|
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
||||||
|
sleep_time = max(0, self._collection_interval - elapsed)
|
||||||
|
|
||||||
|
# Only warn if significantly over (collections can be slow)
|
||||||
|
if elapsed > self._collection_interval * 1.5:
|
||||||
|
logger.warning(f"Collection took {elapsed:.1f}s, longer than interval {self._collection_interval}s")
|
||||||
|
|
||||||
|
await asyncio.sleep(sleep_time)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sensor collection error: {e}")
|
||||||
|
await asyncio.sleep(self._collection_interval)
|
||||||
|
|
||||||
|
async def _collect_all_servers(self):
|
||||||
|
"""Collect sensor data from all active servers in parallel."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
servers = db.query(Server).filter(Server.is_active == True).all()
|
||||||
|
if not servers:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create tasks for parallel collection
|
||||||
|
tasks = []
|
||||||
|
for server in servers:
|
||||||
|
task = self._collect_server_with_timeout(server)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
# Run all collections concurrently with timeout protection
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Process results and batch store in database
|
||||||
|
all_sensor_data = []
|
||||||
|
all_fan_data = []
|
||||||
|
|
||||||
|
for server, result in zip(servers, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.debug(f"Server {server.name} collection failed: {result}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result:
|
||||||
|
temps, fans = result
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Prepare batch inserts
|
||||||
|
for temp in temps:
|
||||||
|
all_sensor_data.append({
|
||||||
|
'server_id': server.id,
|
||||||
|
'sensor_name': temp.name,
|
||||||
|
'sensor_type': 'temperature',
|
||||||
|
'value': temp.value,
|
||||||
|
'unit': '°C',
|
||||||
|
'timestamp': now
|
||||||
|
})
|
||||||
|
|
||||||
|
for fan in fans:
|
||||||
|
all_fan_data.append({
|
||||||
|
'server_id': server.id,
|
||||||
|
'fan_number': fan.fan_number,
|
||||||
|
'fan_id': getattr(fan, 'fan_id', str(fan.fan_number)),
|
||||||
|
'speed_rpm': fan.speed_rpm,
|
||||||
|
'speed_percent': fan.speed_percent,
|
||||||
|
'timestamp': now
|
||||||
|
})
|
||||||
|
|
||||||
|
server.last_seen = now
|
||||||
|
|
||||||
|
# Batch insert for better performance
|
||||||
|
if all_sensor_data:
|
||||||
|
db.bulk_insert_mappings(SensorData, all_sensor_data)
|
||||||
|
if all_fan_data:
|
||||||
|
db.bulk_insert_mappings(FanData, all_fan_data)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.debug(f"Collected data from {len([r for r in results if not isinstance(r, Exception)])}/{len(servers)} servers")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def _collect_server_with_timeout(self, server: Server) -> Optional[tuple]:
|
||||||
|
"""Collect sensor data from a single server with timeout protection."""
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
self._collect_server(server),
|
||||||
|
timeout=30.0 # Max 30 seconds per server (IPMI can be slow)
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"Collection timeout for {server.name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _collect_server(self, server: Server) -> Optional[tuple]:
|
||||||
|
"""Collect sensor data from a single server."""
|
||||||
|
try:
|
||||||
|
from backend.auth import decrypt_password
|
||||||
|
from backend.main import sensor_cache
|
||||||
|
|
||||||
|
# Run blocking IPMI operations in thread pool
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
client = IPMIClient(
|
||||||
|
host=server.ipmi_host,
|
||||||
|
username=server.ipmi_username,
|
||||||
|
password=decrypt_password(server.ipmi_encrypted_password),
|
||||||
|
port=server.ipmi_port,
|
||||||
|
vendor=server.vendor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
connected = await loop.run_in_executor(self._executor, client.test_connection)
|
||||||
|
if not connected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get sensor data in parallel using thread pool
|
||||||
|
temps_future = loop.run_in_executor(self._executor, client.get_temperatures)
|
||||||
|
fans_future = loop.run_in_executor(self._executor, client.get_fan_speeds)
|
||||||
|
power_future = loop.run_in_executor(self._executor, client.get_power_consumption)
|
||||||
|
|
||||||
|
temps, fans, power = await asyncio.gather(
|
||||||
|
temps_future, fans_future, power_future
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate summary metrics
|
||||||
|
max_temp = max((t.value for t in temps if t.value is not None), default=0)
|
||||||
|
avg_fan = sum(f.speed_percent for f in fans if f.speed_percent is not None) / len(fans) if fans else 0
|
||||||
|
|
||||||
|
# Extract current power consumption
|
||||||
|
current_power = None
|
||||||
|
if power and isinstance(power, dict):
|
||||||
|
import re
|
||||||
|
for key, value in power.items():
|
||||||
|
if 'current' in key.lower() and 'power' in key.lower():
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)', str(value))
|
||||||
|
if match:
|
||||||
|
current_power = float(match.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Prepare cache data - format must match response schemas
|
||||||
|
cache_data = {
|
||||||
|
"max_temp": max_temp,
|
||||||
|
"avg_fan_speed": round(avg_fan, 1),
|
||||||
|
"power_consumption": current_power,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"temps": [{"name": t.name, "value": t.value, "location": t.location, "status": getattr(t, 'status', 'ok')} for t in temps],
|
||||||
|
"fans": [{"fan_id": getattr(f, 'fan_id', f'0x0{f.fan_number-1}'), "fan_number": f.fan_number, "speed_percent": f.speed_percent, "speed_rpm": f.speed_rpm} for f in fans],
|
||||||
|
"power_raw": power if isinstance(power, dict) else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store in cache
|
||||||
|
await sensor_cache.set(server.id, cache_data)
|
||||||
|
|
||||||
|
logger.info(f"Collected and cached sensors for {server.name}: temp={max_temp:.1f}°C, fan={avg_fan:.1f}%")
|
||||||
|
return temps, fans
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to collect sensors for {server.name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _cleanup_old_data(self):
|
||||||
|
"""Clean up old sensor data to prevent database bloat."""
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Keep only last 24 hours of detailed sensor data
|
||||||
|
cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||||
|
|
||||||
|
# Delete old sensor data
|
||||||
|
deleted_sensors = db.query(SensorData).filter(
|
||||||
|
SensorData.timestamp < cutoff
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete old fan data
|
||||||
|
deleted_fans = db.query(FanData).filter(
|
||||||
|
FanData.timestamp < cutoff
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if deleted_sensors > 0 or deleted_fans > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted_sensors} sensor records and {deleted_fans} fan records")
|
||||||
|
|
||||||
|
self._last_cleanup = datetime.utcnow()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database cleanup failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global controller instance
|
||||||
|
fan_controller = FanController()
|
||||||
|
sensor_collector = SensorCollector(max_workers=4)
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_fan_controller():
|
||||||
|
"""Initialize and start the fan controller and sensor collector."""
|
||||||
|
await sensor_collector.start()
|
||||||
|
await fan_controller.start()
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_fan_controller():
|
||||||
|
"""Shutdown the fan controller and sensor collector."""
|
||||||
|
await fan_controller.stop()
|
||||||
|
await sensor_collector.stop()
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
"""IPMI client for communicating with servers."""
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Optional, Tuple, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SensorReading:
|
||||||
|
"""Sensor reading data."""
|
||||||
|
name: str
|
||||||
|
sensor_type: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FanReading:
|
||||||
|
"""Fan speed reading."""
|
||||||
|
fan_id: str
|
||||||
|
fan_number: int
|
||||||
|
speed_rpm: Optional[int]
|
||||||
|
speed_percent: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TemperatureReading:
|
||||||
|
"""Temperature reading."""
|
||||||
|
name: str
|
||||||
|
location: str
|
||||||
|
value: float
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class IPMIClient:
|
||||||
|
"""IPMI client for server communication."""
|
||||||
|
|
||||||
|
# Fan number mapping: IPMI ID -> Physical fan number
|
||||||
|
FAN_MAPPING = {
|
||||||
|
"0x00": 1, "0x01": 2, "0x02": 3, "0x03": 4,
|
||||||
|
"0x04": 5, "0x05": 6, "0x06": 7, "0x07": 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hex to percent conversion
|
||||||
|
HEX_TO_PERCENT = {f"0x{i:02x}": i for i in range(101)}
|
||||||
|
PERCENT_TO_HEX = {i: f"0x{i:02x}" for i in range(101)}
|
||||||
|
|
||||||
|
def __init__(self, host: str, username: str, password: str, port: int = 623, vendor: str = "dell"):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
self.vendor = vendor.lower()
|
||||||
|
self.base_cmd = [
|
||||||
|
settings.IPMITOOL_PATH,
|
||||||
|
"-I", "lanplus",
|
||||||
|
"-H", host,
|
||||||
|
"-U", username,
|
||||||
|
"-P", password,
|
||||||
|
"-p", str(port),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _run_command(self, args: List[str], timeout: int = 30) -> Tuple[bool, str, str]:
|
||||||
|
"""Run IPMI command and return success, stdout, stderr."""
|
||||||
|
cmd = self.base_cmd + args
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
success = result.returncode == 0
|
||||||
|
if not success:
|
||||||
|
logger.error(f"IPMI command failed: {result.stderr}")
|
||||||
|
return success, result.stdout, result.stderr
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"IPMI command timed out after {timeout}s")
|
||||||
|
return False, "", "Command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"IPMI command error: {e}")
|
||||||
|
return False, "", str(e)
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test IPMI connection."""
|
||||||
|
success, stdout, stderr = self._run_command(["mc", "info"], timeout=10)
|
||||||
|
return success
|
||||||
|
|
||||||
|
def get_mc_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get Management Controller info."""
|
||||||
|
success, stdout, stderr = self._run_command(["mc", "info"])
|
||||||
|
if not success:
|
||||||
|
return {"error": stderr}
|
||||||
|
|
||||||
|
info = {}
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
info[key.strip()] = value.strip()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def enable_manual_fan_control(self) -> bool:
|
||||||
|
"""Enable manual fan control."""
|
||||||
|
# Dell command: raw 0x30 0x30 0x01 0x00
|
||||||
|
success, _, _ = self._run_command(["raw", "0x30", "0x30", "0x01", "0x00"])
|
||||||
|
return success
|
||||||
|
|
||||||
|
def disable_manual_fan_control(self) -> bool:
|
||||||
|
"""Disable manual fan control (return to automatic)."""
|
||||||
|
# Dell command: raw 0x30 0x30 0x01 0x01
|
||||||
|
success, _, _ = self._run_command(["raw", "0x30", "0x30", "0x01", "0x01"])
|
||||||
|
return success
|
||||||
|
|
||||||
|
def set_fan_speed(self, fan_id: str, speed_percent: int) -> bool:
|
||||||
|
"""
|
||||||
|
Set fan speed.
|
||||||
|
fan_id: '0xff' for all fans, or '0x00', '0x01', etc. for specific fan
|
||||||
|
speed_percent: 0-100
|
||||||
|
"""
|
||||||
|
if speed_percent < 0:
|
||||||
|
speed_percent = 0
|
||||||
|
if speed_percent > 100:
|
||||||
|
speed_percent = 100
|
||||||
|
|
||||||
|
hex_speed = self.PERCENT_TO_HEX.get(speed_percent, "0x32")
|
||||||
|
success, _, _ = self._run_command([
|
||||||
|
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
|
||||||
|
])
|
||||||
|
return success
|
||||||
|
|
||||||
|
def set_all_fans_speed(self, speed_percent: int) -> bool:
|
||||||
|
"""Set all fans to the same speed."""
|
||||||
|
return self.set_fan_speed("0xff", speed_percent)
|
||||||
|
|
||||||
|
def get_third_party_pcie_response(self) -> Optional[bool]:
|
||||||
|
"""Get 3rd party PCIe card response state."""
|
||||||
|
success, stdout, _ = self._run_command([
|
||||||
|
"raw", "0x30", "0xce", "0x01", "0x16", "0x05", "0x00", "0x00", "0x00"
|
||||||
|
])
|
||||||
|
if not success:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse response: 00 00 00 = Enabled, 01 00 00 = Disabled
|
||||||
|
parts = stdout.strip().split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return parts[0] == "00" # True if enabled
|
||||||
|
return None
|
||||||
|
|
||||||
|
def enable_third_party_pcie_response(self) -> bool:
|
||||||
|
"""Enable 3rd party PCIe card response."""
|
||||||
|
success, _, _ = self._run_command([
|
||||||
|
"raw", "0x30", "0xce", "0x00", "0x16", "0x05", "0x00", "0x00", "0x00",
|
||||||
|
"0x05", "0x00", "0x00", "0x00", "0x00"
|
||||||
|
])
|
||||||
|
return success
|
||||||
|
|
||||||
|
def disable_third_party_pcie_response(self) -> bool:
|
||||||
|
"""Disable 3rd party PCIe card response."""
|
||||||
|
success, _, _ = self._run_command([
|
||||||
|
"raw", "0x30", "0xce", "0x00", "0x16", "0x05", "0x00", "0x00", "0x00",
|
||||||
|
"0x05", "0x00", "0x01", "0x00", "0x00"
|
||||||
|
])
|
||||||
|
return success
|
||||||
|
|
||||||
|
def get_temperatures(self) -> List[TemperatureReading]:
|
||||||
|
"""Get temperature sensor readings."""
|
||||||
|
success, stdout, _ = self._run_command(["sdr", "type", "temperature"])
|
||||||
|
if not success:
|
||||||
|
return []
|
||||||
|
|
||||||
|
temps = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
# Parse: Sensor Name | 01h | ok | 3.1 | 45 degrees C
|
||||||
|
parts = [p.strip() for p in line.split("|")]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
name = parts[0]
|
||||||
|
status = parts[2] if len(parts) > 2 else "unknown"
|
||||||
|
reading = parts[4] if len(parts) > 4 else ""
|
||||||
|
|
||||||
|
# Extract temperature value
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
value = float(match.group(1))
|
||||||
|
# Determine location
|
||||||
|
location = self._determine_temp_location(name)
|
||||||
|
temps.append(TemperatureReading(
|
||||||
|
name=name,
|
||||||
|
location=location,
|
||||||
|
value=value,
|
||||||
|
status=status
|
||||||
|
))
|
||||||
|
return temps
|
||||||
|
|
||||||
|
def _determine_temp_location(self, name: str) -> str:
|
||||||
|
"""Determine temperature sensor location from name."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
if "cpu" in name_lower or "proc" in name_lower:
|
||||||
|
if "1" in name or "one" in name_lower:
|
||||||
|
return "cpu1"
|
||||||
|
elif "2" in name or "two" in name_lower:
|
||||||
|
return "cpu2"
|
||||||
|
return "cpu"
|
||||||
|
elif "inlet" in name_lower or "ambient" in name_lower:
|
||||||
|
return "inlet"
|
||||||
|
elif "exhaust" in name_lower:
|
||||||
|
return "exhaust"
|
||||||
|
elif "chipset" in name_lower or "pch" in name_lower:
|
||||||
|
return "chipset"
|
||||||
|
elif "memory" in name_lower or "dimm" in name_lower:
|
||||||
|
return "memory"
|
||||||
|
elif "psu" in name_lower or "power supply" in name_lower:
|
||||||
|
return "psu"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
def get_fan_speeds(self) -> List[FanReading]:
|
||||||
|
"""Get fan speed readings."""
|
||||||
|
success, stdout, _ = self._run_command(["sdr", "elist", "full"])
|
||||||
|
if not success:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fans = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
# Look for fan entries: Fan1 RPM | 30h | ok | 29.1 | 4200 RPM
|
||||||
|
if "fan" in line.lower() and "rpm" in line.lower():
|
||||||
|
parts = [p.strip() for p in line.split("|")]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
name = parts[0]
|
||||||
|
reading = parts[4]
|
||||||
|
|
||||||
|
# Extract fan number
|
||||||
|
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
|
||||||
|
fan_number = int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
# Map to IPMI fan ID
|
||||||
|
fan_id = None
|
||||||
|
for fid, num in self.FAN_MAPPING.items():
|
||||||
|
if num == fan_number:
|
||||||
|
fan_id = fid
|
||||||
|
break
|
||||||
|
if not fan_id:
|
||||||
|
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00"
|
||||||
|
|
||||||
|
# Extract RPM
|
||||||
|
rpm_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
|
||||||
|
rpm = int(rpm_match.group(1)) if rpm_match else None
|
||||||
|
|
||||||
|
fans.append(FanReading(
|
||||||
|
fan_id=fan_id,
|
||||||
|
fan_number=fan_number,
|
||||||
|
speed_rpm=rpm,
|
||||||
|
speed_percent=None # Calculate from RPM if max known
|
||||||
|
))
|
||||||
|
|
||||||
|
return fans
|
||||||
|
|
||||||
|
def get_all_sensors(self) -> List[SensorReading]:
|
||||||
|
"""Get all sensor readings."""
|
||||||
|
success, stdout, _ = self._run_command(["sdr", "elist", "full"])
|
||||||
|
if not success:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sensors = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
parts = [p.strip() for p in line.split("|")]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
name = parts[0]
|
||||||
|
sensor_type = self._determine_sensor_type(name)
|
||||||
|
reading = parts[4]
|
||||||
|
status = parts[2] if len(parts) > 2 else "unknown"
|
||||||
|
|
||||||
|
# Extract value and unit
|
||||||
|
value, unit = self._parse_sensor_value(reading)
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
sensors.append(SensorReading(
|
||||||
|
name=name,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
value=value,
|
||||||
|
unit=unit,
|
||||||
|
status=status
|
||||||
|
))
|
||||||
|
|
||||||
|
return sensors
|
||||||
|
|
||||||
|
def _determine_sensor_type(self, name: str) -> str:
|
||||||
|
"""Determine sensor type from name."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
if "temp" in name_lower or "degrees" in name_lower:
|
||||||
|
return "temperature"
|
||||||
|
elif "fan" in name_lower or "rpm" in name_lower:
|
||||||
|
return "fan"
|
||||||
|
elif "volt" in name_lower or "v" in name_lower:
|
||||||
|
return "voltage"
|
||||||
|
elif "power" in name_lower or "watt" in name_lower or "psu" in name_lower:
|
||||||
|
return "power"
|
||||||
|
elif "current" in name_lower or "amp" in name_lower:
|
||||||
|
return "current"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
def _parse_sensor_value(self, reading: str) -> Tuple[Optional[float], str]:
|
||||||
|
"""Parse sensor value and unit from reading string."""
|
||||||
|
# Temperature: "45 degrees C"
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), "°C"
|
||||||
|
|
||||||
|
# RPM: "4200 RPM"
|
||||||
|
match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), "RPM"
|
||||||
|
|
||||||
|
# Voltage: "12.05 Volts" or "3.3 V"
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Volts?|V)\b', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), "V"
|
||||||
|
|
||||||
|
# Power: "250 Watts" or "250 W"
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Watts?|W)\b', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), "W"
|
||||||
|
|
||||||
|
# Current: "5.5 Amps" or "5.5 A"
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s*(?:Amps?|A)\b', reading, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), "A"
|
||||||
|
|
||||||
|
# Generic number
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)', reading)
|
||||||
|
if match:
|
||||||
|
return float(match.group(1)), ""
|
||||||
|
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
def get_power_consumption(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get power consumption data (Dell OEM command)."""
|
||||||
|
success, stdout, _ = self._run_command(["delloem", "powermonitor"])
|
||||||
|
if not success:
|
||||||
|
return None
|
||||||
|
|
||||||
|
power_data = {}
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
power_data[key.strip()] = value.strip()
|
||||||
|
return power_data
|
||||||
|
|
||||||
|
def get_power_supply_status(self) -> List[SensorReading]:
|
||||||
|
"""Get power supply sensor data."""
|
||||||
|
success, stdout, _ = self._run_command(["sdr", "type", "Power Supply"])
|
||||||
|
if not success:
|
||||||
|
return []
|
||||||
|
|
||||||
|
psus = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
parts = [p.strip() for p in line.split("|")]
|
||||||
|
if len(parts) >= 3:
|
||||||
|
name = parts[0]
|
||||||
|
status = parts[2] if len(parts) > 2 else "unknown"
|
||||||
|
|
||||||
|
psus.append(SensorReading(
|
||||||
|
name=name,
|
||||||
|
sensor_type="power_supply",
|
||||||
|
value=1.0 if status.lower() == "ok" else 0.0,
|
||||||
|
unit="status",
|
||||||
|
status=status
|
||||||
|
))
|
||||||
|
|
||||||
|
return psus
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.6
|
||||||
|
aiofiles==23.2.1
|
||||||
|
httpx==0.26.0
|
||||||
|
apscheduler==3.10.4
|
||||||
|
psutil==5.9.8
|
||||||
|
asyncpg==0.29.0
|
||||||
|
aiosqlite==0.19.0
|
||||||
|
cryptography==42.0.0
|
||||||
|
asyncssh==2.14.2
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Entry point for running the application directly."""
|
||||||
|
import uvicorn
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.DEBUG,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""Pydantic schemas for API requests and responses."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any, Union
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
|
||||||
|
# User schemas
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Token schemas
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Fan curve schemas
|
||||||
|
class FanCurvePoint(BaseModel):
|
||||||
|
temp: float = Field(..., ge=0, le=150, description="Temperature in Celsius")
|
||||||
|
speed: int = Field(..., ge=0, le=100, description="Fan speed percentage")
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurveBase(BaseModel):
|
||||||
|
name: str = "Default"
|
||||||
|
curve_data: List[FanCurvePoint]
|
||||||
|
sensor_source: str = "cpu"
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurveCreate(FanCurveBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurveResponse(FanCurveBase):
|
||||||
|
id: int
|
||||||
|
server_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
@validator('curve_data', pre=True)
|
||||||
|
def parse_curve_data(cls, v):
|
||||||
|
"""Parse curve_data from JSON string if needed."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
import json
|
||||||
|
return json.loads(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# IPMI Settings schema
|
||||||
|
class IPMISettings(BaseModel):
|
||||||
|
ipmi_host: str = Field(..., description="IPMI IP address or hostname")
|
||||||
|
ipmi_port: int = Field(623, description="IPMI port (default 623)")
|
||||||
|
ipmi_username: str = Field(..., description="IPMI username")
|
||||||
|
ipmi_password: str = Field(..., description="IPMI password")
|
||||||
|
|
||||||
|
|
||||||
|
# SSH Settings schema
|
||||||
|
class SSHSettings(BaseModel):
|
||||||
|
use_ssh: bool = Field(False, description="Enable SSH for sensor data collection")
|
||||||
|
ssh_host: Optional[str] = Field(None, description="SSH host (leave empty to use IPMI host)")
|
||||||
|
ssh_port: int = Field(22, description="SSH port (default 22)")
|
||||||
|
ssh_username: Optional[str] = Field(None, description="SSH username")
|
||||||
|
ssh_password: Optional[str] = Field(None, description="SSH password (or use key)")
|
||||||
|
ssh_key_file: Optional[str] = Field(None, description="Path to SSH private key file")
|
||||||
|
|
||||||
|
|
||||||
|
# Server schemas
|
||||||
|
class ServerBase(BaseModel):
|
||||||
|
name: str = Field(..., description="Server name/display name")
|
||||||
|
vendor: str = Field("dell", description="Server vendor (dell, hpe, supermicro, other)")
|
||||||
|
|
||||||
|
@validator('vendor')
|
||||||
|
def validate_vendor(cls, v):
|
||||||
|
allowed = ['dell', 'hpe', 'supermicro', 'other']
|
||||||
|
if v.lower() not in allowed:
|
||||||
|
raise ValueError(f'Vendor must be one of: {allowed}')
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class ServerCreate(ServerBase):
|
||||||
|
ipmi: IPMISettings
|
||||||
|
ssh: SSHSettings = Field(default_factory=lambda: SSHSettings(use_ssh=False))
|
||||||
|
|
||||||
|
|
||||||
|
class ServerUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
ipmi_host: Optional[str] = None
|
||||||
|
ipmi_port: Optional[int] = None
|
||||||
|
ipmi_username: Optional[str] = None
|
||||||
|
ipmi_password: Optional[str] = None
|
||||||
|
ssh_host: Optional[str] = None
|
||||||
|
ssh_port: Optional[int] = None
|
||||||
|
ssh_username: Optional[str] = None
|
||||||
|
ssh_password: Optional[str] = None
|
||||||
|
ssh_key_file: Optional[str] = None
|
||||||
|
use_ssh: Optional[bool] = None
|
||||||
|
vendor: Optional[str] = None
|
||||||
|
manual_control_enabled: Optional[bool] = None
|
||||||
|
third_party_pcie_response: Optional[bool] = None
|
||||||
|
auto_control_enabled: Optional[bool] = None
|
||||||
|
panic_mode_enabled: Optional[bool] = None
|
||||||
|
panic_timeout_seconds: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServerResponse(ServerBase):
|
||||||
|
id: int
|
||||||
|
ipmi_host: str
|
||||||
|
ipmi_port: int
|
||||||
|
ipmi_username: str
|
||||||
|
ssh_host: Optional[str]
|
||||||
|
ssh_port: int
|
||||||
|
ssh_username: Optional[str]
|
||||||
|
use_ssh: bool
|
||||||
|
manual_control_enabled: bool
|
||||||
|
third_party_pcie_response: bool
|
||||||
|
auto_control_enabled: bool
|
||||||
|
panic_mode_enabled: bool
|
||||||
|
panic_timeout_seconds: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_seen: Optional[datetime]
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDetailResponse(ServerResponse):
|
||||||
|
fan_curves: List[FanCurveResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ServerStatusResponse(BaseModel):
|
||||||
|
server: ServerResponse
|
||||||
|
is_connected: bool
|
||||||
|
controller_status: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# Sensor schemas
|
||||||
|
class SensorReading(BaseModel):
|
||||||
|
name: str
|
||||||
|
sensor_type: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureReading(BaseModel):
|
||||||
|
name: str
|
||||||
|
location: str
|
||||||
|
value: float
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class FanReading(BaseModel):
|
||||||
|
fan_id: str
|
||||||
|
fan_number: int
|
||||||
|
speed_rpm: Optional[int]
|
||||||
|
speed_percent: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class SensorDataResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
sensor_name: str
|
||||||
|
sensor_type: str
|
||||||
|
value: float
|
||||||
|
unit: Optional[str]
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class FanDataResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
fan_number: int
|
||||||
|
fan_id: str
|
||||||
|
speed_rpm: Optional[int]
|
||||||
|
speed_percent: Optional[int]
|
||||||
|
is_manual: bool
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSensorsResponse(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
temperatures: List[TemperatureReading]
|
||||||
|
fans: List[FanReading]
|
||||||
|
all_sensors: List[SensorReading]
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Fan control schemas
|
||||||
|
class FanControlCommand(BaseModel):
|
||||||
|
fan_id: str = Field(default="0xff", description="Fan ID (0xff for all, 0x00-0x07 for specific)")
|
||||||
|
speed_percent: int = Field(..., ge=0, le=100, description="Fan speed percentage")
|
||||||
|
|
||||||
|
|
||||||
|
class FanCurveApply(BaseModel):
|
||||||
|
curve_id: Optional[int] = None
|
||||||
|
curve_data: Optional[List[FanCurvePoint]] = None
|
||||||
|
sensor_source: Optional[str] = "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoControlSettings(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
curve_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# System log schemas
|
||||||
|
class SystemLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
server_id: Optional[int]
|
||||||
|
event_type: str
|
||||||
|
message: str
|
||||||
|
details: Optional[str]
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Setup wizard schemas
|
||||||
|
class SetupStatus(BaseModel):
|
||||||
|
setup_complete: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SetupComplete(BaseModel):
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
confirm_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
@validator('confirm_password')
|
||||||
|
def passwords_match(cls, v, values):
|
||||||
|
if 'password' in values and v != values['password']:
|
||||||
|
raise ValueError('Passwords do not match')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard schemas
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
total_servers: int
|
||||||
|
active_servers: int
|
||||||
|
manual_control_servers: int
|
||||||
|
auto_control_servers: int
|
||||||
|
panic_mode_servers: int
|
||||||
|
recent_logs: List[SystemLogResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDashboardData(BaseModel):
|
||||||
|
server: ServerResponse
|
||||||
|
current_temperatures: List[TemperatureReading]
|
||||||
|
current_fans: List[FanReading]
|
||||||
|
recent_sensor_data: List[SensorDataResponse]
|
||||||
|
recent_fan_data: List[FanDataResponse]
|
||||||
|
power_consumption: Optional[Dict[str, str]]
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""SSH client for connecting to servers to get lm-sensors data."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SensorData:
|
||||||
|
"""Sensor data from lm-sensors."""
|
||||||
|
name: str
|
||||||
|
adapter: str
|
||||||
|
values: Dict[str, float]
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CPUTemp:
|
||||||
|
"""CPU temperature data."""
|
||||||
|
cpu_name: str
|
||||||
|
core_temps: Dict[str, float]
|
||||||
|
package_temp: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SSHClient:
|
||||||
|
"""SSH client for server sensor monitoring."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, username: str, password: Optional[str] = None,
|
||||||
|
port: int = 22, key_file: Optional[str] = None):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
self.key_file = key_file
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to the server via SSH."""
|
||||||
|
try:
|
||||||
|
conn_options = {}
|
||||||
|
if self.password:
|
||||||
|
conn_options['password'] = self.password
|
||||||
|
if self.key_file:
|
||||||
|
conn_options['client_keys'] = [self.key_file]
|
||||||
|
|
||||||
|
self._conn = await asyncssh.connect(
|
||||||
|
self.host,
|
||||||
|
port=self.port,
|
||||||
|
username=self.username,
|
||||||
|
known_hosts=None, # Allow unknown hosts (use with caution)
|
||||||
|
**conn_options
|
||||||
|
)
|
||||||
|
logger.info(f"SSH connected to {self.host}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH connection failed to {self.host}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect from the server."""
|
||||||
|
if self._conn:
|
||||||
|
self._conn.close()
|
||||||
|
await self._conn.wait_closed()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
async def test_connection(self) -> bool:
|
||||||
|
"""Test SSH connection."""
|
||||||
|
if not self._conn:
|
||||||
|
if not await self.connect():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._conn.run('echo "test"', check=True)
|
||||||
|
return result.exit_status == 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH test failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_lmsensors_data(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get sensor data from lm-sensors."""
|
||||||
|
if not self._conn:
|
||||||
|
if not await self.connect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if sensors command exists
|
||||||
|
result = await self._conn.run('which sensors', check=False)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
logger.warning("lm-sensors not installed on remote server")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get sensor data in JSON format
|
||||||
|
result = await self._conn.run('sensors -j', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
else:
|
||||||
|
# Try without JSON format
|
||||||
|
result = await self._conn.run('sensors', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
return self._parse_sensors_text(result.stdout)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get lm-sensors data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_sensors_text(self, output: str) -> Dict[str, Any]:
|
||||||
|
"""Parse plain text sensors output."""
|
||||||
|
data = {}
|
||||||
|
current_adapter = None
|
||||||
|
|
||||||
|
for line in output.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Adapter line
|
||||||
|
if line.startswith('Adapter:'):
|
||||||
|
current_adapter = line.replace('Adapter:', '').strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Chip header
|
||||||
|
if ':' not in line and line:
|
||||||
|
current_chip = line
|
||||||
|
if current_chip not in data:
|
||||||
|
data[current_chip] = {}
|
||||||
|
if current_adapter:
|
||||||
|
data[current_chip]['Adapter'] = current_adapter
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sensor value
|
||||||
|
if ':' in line and current_chip in data:
|
||||||
|
parts = line.split(':')
|
||||||
|
if len(parts) == 2:
|
||||||
|
key = parts[0].strip()
|
||||||
|
value_str = parts[1].strip()
|
||||||
|
# Try to extract numeric value
|
||||||
|
try:
|
||||||
|
# Remove units and extract number
|
||||||
|
value_clean = ''.join(c for c in value_str if c.isdigit() or c == '.' or c == '-')
|
||||||
|
if value_clean:
|
||||||
|
data[current_chip][key] = float(value_clean)
|
||||||
|
except ValueError:
|
||||||
|
data[current_chip][key] = value_str
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_cpu_temperatures(self) -> List[CPUTemp]:
|
||||||
|
"""Get CPU temperatures from lm-sensors."""
|
||||||
|
sensors_data = await self.get_lmsensors_data()
|
||||||
|
if not sensors_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cpu_temps = []
|
||||||
|
|
||||||
|
for chip_name, chip_data in sensors_data.items():
|
||||||
|
# Look for coretemp or k10temp (AMD) chips
|
||||||
|
if 'coretemp' in chip_name.lower() or 'k10temp' in chip_name.lower():
|
||||||
|
core_temps = {}
|
||||||
|
package_temp = None
|
||||||
|
|
||||||
|
for key, value in chip_data.items():
|
||||||
|
# Skip metadata fields
|
||||||
|
if key in ['Adapter']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle nested JSON structure from sensors -j
|
||||||
|
# e.g., "Core 0": {"temp2_input": 31, "temp2_max": 79, ...}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Look for temp*_input field which contains the actual temperature
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
if 'input' in sub_key.lower() and isinstance(sub_value, (int, float)):
|
||||||
|
temp_value = float(sub_value)
|
||||||
|
if 'core' in key.lower():
|
||||||
|
core_temps[key] = temp_value
|
||||||
|
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
|
||||||
|
package_temp = temp_value
|
||||||
|
break # Only take the first _input value
|
||||||
|
|
||||||
|
# Handle flat structure (fallback for text parsing)
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
if 'core' in key.lower():
|
||||||
|
core_temps[key] = float(value)
|
||||||
|
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
|
||||||
|
package_temp = float(value)
|
||||||
|
|
||||||
|
if core_temps or package_temp:
|
||||||
|
cpu_temps.append(CPUTemp(
|
||||||
|
cpu_name=chip_name,
|
||||||
|
core_temps=core_temps,
|
||||||
|
package_temp=package_temp
|
||||||
|
))
|
||||||
|
|
||||||
|
return cpu_temps
|
||||||
|
|
||||||
|
async def get_system_info(self) -> Optional[Dict[str, str]]:
|
||||||
|
"""Get basic system information."""
|
||||||
|
if not self._conn:
|
||||||
|
if not await self.connect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = {}
|
||||||
|
|
||||||
|
# CPU info
|
||||||
|
result = await self._conn.run('cat /proc/cpuinfo | grep "model name" | head -1', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
info['cpu'] = result.stdout.split(':')[1].strip() if ':' in result.stdout else 'Unknown'
|
||||||
|
|
||||||
|
# Memory info
|
||||||
|
result = await self._conn.run('free -h | grep Mem', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
parts = result.stdout.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
info['memory'] = parts[1]
|
||||||
|
|
||||||
|
# OS info
|
||||||
|
result = await self._conn.run('cat /etc/os-release | grep PRETTY_NAME', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
info['os'] = result.stdout.split('=')[1].strip().strip('"')
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
result = await self._conn.run('uptime -p', check=False)
|
||||||
|
if result.exit_status == 0:
|
||||||
|
info['uptime'] = result.stdout.strip()
|
||||||
|
|
||||||
|
return info
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get system info: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def execute_command(self, command: str) -> tuple[int, str, str]:
|
||||||
|
"""Execute a custom command on the server."""
|
||||||
|
if not self._conn:
|
||||||
|
if not await self.connect():
|
||||||
|
return -1, "", "Not connected"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._conn.run(command, check=False)
|
||||||
|
return result.exit_status, result.stdout, result.stderr
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Command execution failed: {e}")
|
||||||
|
return -1, "", str(e)
|
||||||
141
backup.sh
|
|
@ -1,141 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# IPMI Controller - Backup and Restore
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DATA_DIR="${DATA_DIR:-$SCRIPT_DIR/data}"
|
|
||||||
BACKUP_DIR="${BACKUP_DIR:-$SCRIPT_DIR/backups}"
|
|
||||||
|
|
||||||
show_help() {
|
|
||||||
echo "IPMI Controller - Backup/Restore Tool"
|
|
||||||
echo ""
|
|
||||||
echo "Usage:"
|
|
||||||
echo " $0 backup - Create backup"
|
|
||||||
echo " $0 restore [filename] - Restore from backup"
|
|
||||||
echo " $0 list - List available backups"
|
|
||||||
echo " $0 auto - Auto-backup (cron-friendly)"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
create_backup() {
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_FILE="$BACKUP_DIR/ipmi-controller-backup-$TIMESTAMP.tar.gz"
|
|
||||||
|
|
||||||
echo "📦 Creating backup..."
|
|
||||||
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✅ Backup created: $BACKUP_FILE"
|
|
||||||
echo ""
|
|
||||||
ls -lh "$BACKUP_FILE"
|
|
||||||
else
|
|
||||||
echo "❌ Backup failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_backup() {
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "❌ Please specify backup file"
|
|
||||||
list_backups
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_FILE="$BACKUP_DIR/$1"
|
|
||||||
if [ ! -f "$BACKUP_FILE" ]; then
|
|
||||||
BACKUP_FILE="$1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$BACKUP_FILE" ]; then
|
|
||||||
echo "❌ Backup file not found: $1"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⚠️ This will overwrite current settings!"
|
|
||||||
read -p "Are you sure? (yes/no): " confirm
|
|
||||||
|
|
||||||
if [ "$confirm" != "yes" ]; then
|
|
||||||
echo "Aborted"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create safety backup first
|
|
||||||
echo "📦 Creating safety backup..."
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
tar -czf "$BACKUP_DIR/safety-backup-before-restore-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data/ 2>/dev/null
|
|
||||||
|
|
||||||
# Stop service if running
|
|
||||||
if systemctl is-active --quiet ipmi-controller 2>/dev/null; then
|
|
||||||
echo "🛑 Stopping service..."
|
|
||||||
sudo systemctl stop ipmi-controller
|
|
||||||
WAS_RUNNING=true
|
|
||||||
else
|
|
||||||
WAS_RUNNING=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
echo "📥 Restoring from backup..."
|
|
||||||
tar -xzf "$BACKUP_FILE" -C "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✅ Restore complete"
|
|
||||||
|
|
||||||
# Restart service if it was running
|
|
||||||
if [ "$WAS_RUNNING" = true ]; then
|
|
||||||
echo "🚀 Starting service..."
|
|
||||||
sudo systemctl start ipmi-controller
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Restore failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
list_backups() {
|
|
||||||
echo "📂 Available backups:"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ -d "$BACKUP_DIR" ] && [ "$(ls -A "$BACKUP_DIR")" ]; then
|
|
||||||
ls -lh "$BACKUP_DIR"/*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
|
||||||
else
|
|
||||||
echo " No backups found"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
auto_backup() {
|
|
||||||
# This is cron-friendly - keeps only last 30 days
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_FILE="$BACKUP_DIR/auto-backup-$TIMESTAMP.tar.gz"
|
|
||||||
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
|
|
||||||
|
|
||||||
# Clean old backups (keep last 30 days)
|
|
||||||
find "$BACKUP_DIR" -name "auto-backup-*.tar.gz" -mtime +30 -delete 2>/dev/null
|
|
||||||
|
|
||||||
echo "Auto-backup complete: $BACKUP_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
backup)
|
|
||||||
create_backup
|
|
||||||
;;
|
|
||||||
restore)
|
|
||||||
restore_backup "$2"
|
|
||||||
;;
|
|
||||||
list)
|
|
||||||
list_backups
|
|
||||||
;;
|
|
||||||
auto)
|
|
||||||
auto_backup
|
|
||||||
;;
|
|
||||||
help|--help|-h)
|
|
||||||
show_help
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
show_help
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
167
data/config.json
|
|
@ -1,167 +0,0 @@
|
||||||
{
|
|
||||||
"ipmi_host": "192.168.5.191",
|
|
||||||
"ipmi_username": "root",
|
|
||||||
"ipmi_password": "calvin",
|
|
||||||
"ipmi_port": 623,
|
|
||||||
"http_sensor_enabled": true,
|
|
||||||
"http_sensor_url": "http://192.168.5.200:8888",
|
|
||||||
"http_sensor_timeout": 10,
|
|
||||||
"enabled": true,
|
|
||||||
"poll_interval": 10,
|
|
||||||
"fan_update_interval": 10,
|
|
||||||
"min_speed": 10,
|
|
||||||
"max_speed": 100,
|
|
||||||
"panic_temp": 85,
|
|
||||||
"panic_speed": 100,
|
|
||||||
"panic_on_no_data": true,
|
|
||||||
"no_data_timeout": 60,
|
|
||||||
"primary_sensor": "cpu",
|
|
||||||
"sensor_preference": "auto",
|
|
||||||
"fans": {},
|
|
||||||
"fan_groups": {
|
|
||||||
"1 & 2": {
|
|
||||||
"fan_ids": [
|
|
||||||
"0x00",
|
|
||||||
"0x01"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active_curve": "Balanced",
|
|
||||||
"fan_curves": {
|
|
||||||
"Balanced": {
|
|
||||||
"points": [
|
|
||||||
{
|
|
||||||
"temp": 30,
|
|
||||||
"speed": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 35,
|
|
||||||
"speed": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 40,
|
|
||||||
"speed": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 45,
|
|
||||||
"speed": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 50,
|
|
||||||
"speed": 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 55,
|
|
||||||
"speed": 40
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 60,
|
|
||||||
"speed": 55
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 65,
|
|
||||||
"speed": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 70,
|
|
||||||
"speed": 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 75,
|
|
||||||
"speed": 95
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 80,
|
|
||||||
"speed": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Silent": {
|
|
||||||
"points": [
|
|
||||||
{
|
|
||||||
"temp": 30,
|
|
||||||
"speed": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 40,
|
|
||||||
"speed": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 50,
|
|
||||||
"speed": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 55,
|
|
||||||
"speed": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 60,
|
|
||||||
"speed": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 65,
|
|
||||||
"speed": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 70,
|
|
||||||
"speed": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 75,
|
|
||||||
"speed": 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 80,
|
|
||||||
"speed": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Performance": {
|
|
||||||
"points": [
|
|
||||||
{
|
|
||||||
"temp": 30,
|
|
||||||
"speed": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 35,
|
|
||||||
"speed": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 40,
|
|
||||||
"speed": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 45,
|
|
||||||
"speed": 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 50,
|
|
||||||
"speed": 55
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 55,
|
|
||||||
"speed": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 60,
|
|
||||||
"speed": 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 65,
|
|
||||||
"speed": 95
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"temp": 70,
|
|
||||||
"speed": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"users": {"admin": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae"}}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Deploy IPMI Controller to production
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PROD_HOST="192.168.5.211"
|
|
||||||
PROD_USER="devmatrix"
|
|
||||||
PROD_DIR="/opt/ipmi-controller"
|
|
||||||
SERVICE_NAME="ipmi-controller"
|
|
||||||
|
|
||||||
echo "=== Deploying IPMI Controller to Production ==="
|
|
||||||
echo "Target: $PROD_HOST"
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
echo "Installing system dependencies..."
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo apt update -qq && sudo apt install -y ipmitool python3-pip 2>/dev/null | tail -3"
|
|
||||||
|
|
||||||
# Create remote directory
|
|
||||||
echo "Creating remote directory..."
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo mkdir -p $PROD_DIR && sudo chown $PROD_USER:$PROD_USER $PROD_DIR"
|
|
||||||
|
|
||||||
# Copy files
|
|
||||||
echo "Copying files..."
|
|
||||||
rsync -avz --exclude='.git' --exclude='__pycache__' --exclude='data/*.json' \
|
|
||||||
./ $PROD_USER@$PROD_HOST:$PROD_DIR/
|
|
||||||
|
|
||||||
# Install systemd service
|
|
||||||
echo "Installing systemd service..."
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo cp $PROD_DIR/ipmi-controller.service /etc/systemd/system/"
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo systemctl daemon-reload"
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo systemctl enable $SERVICE_NAME"
|
|
||||||
|
|
||||||
# Restart service
|
|
||||||
echo "Restarting service..."
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo systemctl restart $SERVICE_NAME"
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
echo "Checking service status..."
|
|
||||||
sleep 2
|
|
||||||
ssh $PROD_USER@$PROD_HOST "sudo systemctl status $SERVICE_NAME --no-pager"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Deployment Complete ==="
|
|
||||||
echo "IPMI Controller is now running at: http://$PROD_HOST:8000"
|
|
||||||
echo ""
|
|
||||||
echo "To check logs: ssh $PROD_USER@$PROD_HOST 'sudo journalctl -u $SERVICE_NAME -f'"
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ipmi-controller:
|
ipmi-fan-control:
|
||||||
build: .
|
build: .
|
||||||
container_name: ipmi-controller
|
container_name: ipmi-fan-control
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
|
||||||
# Persist data directory
|
|
||||||
- ./data:/app/data
|
|
||||||
# Optional: mount ipmitool from host if needed
|
|
||||||
- /usr/bin/ipmitool:/usr/bin/ipmitool:ro
|
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
# Required for ipmitool to work in container
|
- PANIC_TIMEOUT_SECONDS=${PANIC_TIMEOUT_SECONDS:-60}
|
||||||
privileged: true
|
- PANIC_FAN_SPEED=${PANIC_FAN_SPEED:-100}
|
||||||
network_mode: host
|
- DEBUG=${DEBUG:-true}
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- ipmi-network
|
||||||
|
# For Windows Docker Desktop, we need this for proper shutdown
|
||||||
|
stop_grace_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ipmi-network:
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ipmi-fan-control:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://ipmi-fan-control:8000/api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,800 +0,0 @@
|
||||||
"""
|
|
||||||
IPMI Controller - Advanced Fan Control for Dell Servers
|
|
||||||
Features: Fan groups, multiple curves, HTTP sensors, panic mode
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import requests
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from typing import List, Dict, Optional, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.StreamHandler(),
|
|
||||||
logging.FileHandler('/tmp/ipmi-controller.log')
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TemperatureReading:
|
|
||||||
name: str
|
|
||||||
location: str
|
|
||||||
value: float
|
|
||||||
status: str
|
|
||||||
source: str = "ipmi" # ipmi, http, ssh
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FanReading:
|
|
||||||
fan_id: str
|
|
||||||
fan_number: int
|
|
||||||
speed_rpm: Optional[int]
|
|
||||||
speed_percent: Optional[int]
|
|
||||||
name: Optional[str] = None # Custom name
|
|
||||||
group: Optional[str] = None # Fan group
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FanCurve:
|
|
||||||
name: str
|
|
||||||
points: List[Dict[str, float]] # [{"temp": 30, "speed": 15}, ...]
|
|
||||||
sensor_source: str = "cpu" # Which sensor to use
|
|
||||||
applies_to: str = "all" # "all", group name, or fan_id
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPSensorClient:
|
|
||||||
"""Client for fetching sensor data from HTTP endpoint (lm-sensors over HTTP)."""
|
|
||||||
|
|
||||||
def __init__(self, url: str, timeout: int = 10):
|
|
||||||
self.url = url
|
|
||||||
self.timeout = timeout
|
|
||||||
self.last_reading = None
|
|
||||||
self.consecutive_failures = 0
|
|
||||||
|
|
||||||
def fetch_sensors(self) -> List[TemperatureReading]:
|
|
||||||
"""Fetch sensor data from HTTP endpoint."""
|
|
||||||
try:
|
|
||||||
response = requests.get(self.url, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse lm-sensors style output
|
|
||||||
temps = self._parse_sensors_output(response.text)
|
|
||||||
self.consecutive_failures = 0
|
|
||||||
return temps
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch HTTP sensors from {self.url}: {e}")
|
|
||||||
self.consecutive_failures += 1
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _parse_sensors_output(self, output: str) -> List[TemperatureReading]:
|
|
||||||
"""Parse lm-sensors -u style output."""
|
|
||||||
temps = []
|
|
||||||
current_chip = ""
|
|
||||||
|
|
||||||
for line in output.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
# New chip section - chip names typically don't have spaces or colons at start
|
|
||||||
if line and not line.startswith("_") and ":" not in line and not line[0].isdigit():
|
|
||||||
if "Adapter:" not in line and "ERROR" not in line.upper():
|
|
||||||
current_chip = line
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Temperature reading
|
|
||||||
if "_input:" in line and "temp" in line.lower():
|
|
||||||
parts = line.split(":")
|
|
||||||
if len(parts) >= 2:
|
|
||||||
name = parts[0].strip()
|
|
||||||
try:
|
|
||||||
value = float(parts[1].strip().split()[0]) # Handle "34.000" or "34.000 (high ="
|
|
||||||
location = self._classify_sensor_name(name, current_chip)
|
|
||||||
temps.append(TemperatureReading(
|
|
||||||
name=f"{current_chip}/{name}",
|
|
||||||
location=location,
|
|
||||||
value=value,
|
|
||||||
status="ok",
|
|
||||||
source="http"
|
|
||||||
))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return temps
|
|
||||||
|
|
||||||
def _classify_sensor_name(self, name: str, chip: str) -> str:
|
|
||||||
"""Classify sensor location from name with detailed categories."""
|
|
||||||
import re
|
|
||||||
name_lower = name.lower()
|
|
||||||
chip_lower = chip.lower()
|
|
||||||
|
|
||||||
# Check chip name first for CPU identification
|
|
||||||
if "coretemp" in chip_lower:
|
|
||||||
# Extract CPU number from chip (coretemp-isa-0000 = cpu1, coretemp-isa-0001 = cpu2)
|
|
||||||
if "0001" in chip or "isa-0001" in chip_lower:
|
|
||||||
return "cpu2"
|
|
||||||
return "cpu1"
|
|
||||||
|
|
||||||
# Check sensor name for core temps
|
|
||||||
if "core" in name_lower:
|
|
||||||
# Try to determine which CPU based on core number
|
|
||||||
core_match = re.search(r'core\s*(\d+)', name_lower)
|
|
||||||
if core_match:
|
|
||||||
core_num = int(core_match.group(1))
|
|
||||||
if core_num >= 6:
|
|
||||||
return "cpu2"
|
|
||||||
return "cpu1"
|
|
||||||
return "cpu"
|
|
||||||
elif "package" in name_lower:
|
|
||||||
return "cpu"
|
|
||||||
elif "tdie" in name_lower or "tctl" in name_lower:
|
|
||||||
return "cpu"
|
|
||||||
elif "nvme" in name_lower or "composite" in name_lower:
|
|
||||||
return "nvme"
|
|
||||||
elif "raid" in name_lower or "megaraid" in name_lower:
|
|
||||||
return "raid"
|
|
||||||
elif "pcie" in name_lower:
|
|
||||||
return "pcie"
|
|
||||||
elif "inlet" in name_lower or "ambient" in name_lower or "room" in name_lower:
|
|
||||||
return "ambient"
|
|
||||||
elif "exhaust" in name_lower or "outlet" in name_lower:
|
|
||||||
return "exhaust"
|
|
||||||
elif "inlet" in name_lower:
|
|
||||||
return "inlet"
|
|
||||||
elif "loc1" in name_lower or "loc2" in name_lower or "chipset" in name_lower:
|
|
||||||
return "chipset"
|
|
||||||
return "other"
|
|
||||||
|
|
||||||
def is_healthy(self) -> bool:
|
|
||||||
return self.consecutive_failures < 3
|
|
||||||
|
|
||||||
|
|
||||||
class IPMIFanController:
|
|
||||||
"""IPMI fan controller with advanced features."""
|
|
||||||
|
|
||||||
def __init__(self, host: str, username: str, password: str, port: int = 623):
|
|
||||||
self.host = host
|
|
||||||
self.username = username
|
|
||||||
self.password = password
|
|
||||||
self.port = port
|
|
||||||
self.manual_mode = False
|
|
||||||
self.last_successful_read = None
|
|
||||||
self.consecutive_failures = 0
|
|
||||||
self.max_failures = 5
|
|
||||||
|
|
||||||
def _run_ipmi(self, args: List[str], timeout: int = 15) -> Tuple[bool, str]:
|
|
||||||
"""Run IPMI command with error handling."""
|
|
||||||
cmd = [
|
|
||||||
"ipmitool", "-I", "lanplus",
|
|
||||||
"-H", self.host,
|
|
||||||
"-U", self.username,
|
|
||||||
"-P", self.password,
|
|
||||||
"-p", str(self.port)
|
|
||||||
] + args
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.consecutive_failures = 0
|
|
||||||
return True, result.stdout
|
|
||||||
else:
|
|
||||||
self.consecutive_failures += 1
|
|
||||||
logger.warning(f"IPMI command failed: {result.stderr}")
|
|
||||||
return False, result.stderr
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.consecutive_failures += 1
|
|
||||||
logger.error(f"IPMI command timed out after {timeout}s")
|
|
||||||
return False, "Timeout"
|
|
||||||
except Exception as e:
|
|
||||||
self.consecutive_failures += 1
|
|
||||||
logger.error(f"IPMI command error: {e}")
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
def test_connection(self) -> bool:
|
|
||||||
"""Test IPMI connection."""
|
|
||||||
success, _ = self._run_ipmi(["mc", "info"], timeout=10)
|
|
||||||
return success
|
|
||||||
|
|
||||||
def enable_manual_fan_control(self) -> bool:
|
|
||||||
"""Enable manual fan control mode."""
|
|
||||||
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
|
|
||||||
if success:
|
|
||||||
self.manual_mode = True
|
|
||||||
logger.info("Manual fan control enabled")
|
|
||||||
return success
|
|
||||||
|
|
||||||
def disable_manual_fan_control(self) -> bool:
|
|
||||||
"""Return to automatic fan control."""
|
|
||||||
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
|
|
||||||
if success:
|
|
||||||
self.manual_mode = False
|
|
||||||
logger.info("Automatic fan control restored")
|
|
||||||
return success
|
|
||||||
|
|
||||||
def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool:
|
|
||||||
"""Set fan speed (0-100%). fan_id 0xff = all fans."""
|
|
||||||
speed_percent = max(0, min(100, speed_percent))
|
|
||||||
hex_speed = f"0x{speed_percent:02x}"
|
|
||||||
success, _ = self._run_ipmi([
|
|
||||||
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
|
|
||||||
])
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Fan {fan_id} speed set to {speed_percent}%")
|
|
||||||
return success
|
|
||||||
|
|
||||||
def get_temperatures(self) -> List[TemperatureReading]:
|
|
||||||
"""Get temperature readings from all sensors."""
|
|
||||||
success, output = self._run_ipmi(["sdr", "type", "temperature"])
|
|
||||||
if not success:
|
|
||||||
return []
|
|
||||||
|
|
||||||
temps = []
|
|
||||||
for line in output.splitlines():
|
|
||||||
parts = [p.strip() for p in line.split("|")]
|
|
||||||
if len(parts) >= 5:
|
|
||||||
name = parts[0]
|
|
||||||
status = parts[2] if len(parts) > 2 else "unknown"
|
|
||||||
reading = parts[4]
|
|
||||||
|
|
||||||
match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
value = float(match.group(1))
|
|
||||||
location = self._classify_temp_location(name)
|
|
||||||
temps.append(TemperatureReading(
|
|
||||||
name=name,
|
|
||||||
location=location,
|
|
||||||
value=value,
|
|
||||||
status=status,
|
|
||||||
source="ipmi"
|
|
||||||
))
|
|
||||||
return temps
|
|
||||||
|
|
||||||
def get_fan_speeds(self) -> List[FanReading]:
|
|
||||||
"""Get current fan speeds."""
|
|
||||||
success, output = self._run_ipmi(["sdr", "elist", "full"])
|
|
||||||
if not success:
|
|
||||||
return []
|
|
||||||
|
|
||||||
fans = []
|
|
||||||
for line in output.splitlines():
|
|
||||||
if "fan" in line.lower() and "rpm" in line.lower():
|
|
||||||
parts = [p.strip() for p in line.split("|")]
|
|
||||||
if len(parts) >= 5:
|
|
||||||
name = parts[0]
|
|
||||||
reading = parts[4]
|
|
||||||
|
|
||||||
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
|
|
||||||
fan_number = int(match.group(1)) if match else 0
|
|
||||||
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00"
|
|
||||||
|
|
||||||
rpm_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
|
|
||||||
rpm = int(rpm_match.group(1)) if rpm_match else None
|
|
||||||
|
|
||||||
fans.append(FanReading(
|
|
||||||
fan_id=fan_id,
|
|
||||||
fan_number=fan_number,
|
|
||||||
speed_rpm=rpm,
|
|
||||||
speed_percent=None
|
|
||||||
))
|
|
||||||
return fans
|
|
||||||
|
|
||||||
def _classify_temp_location(self, name: str) -> str:
|
|
||||||
"""Classify temperature sensor location."""
|
|
||||||
name_lower = name.lower()
|
|
||||||
if "cpu" in name_lower or "proc" in name_lower:
|
|
||||||
if "1" in name or "one" in name_lower:
|
|
||||||
return "cpu1"
|
|
||||||
elif "2" in name or "two" in name_lower:
|
|
||||||
return "cpu2"
|
|
||||||
return "cpu"
|
|
||||||
elif "inlet" in name_lower or "ambient" in name_lower:
|
|
||||||
return "inlet"
|
|
||||||
elif "exhaust" in name_lower:
|
|
||||||
return "exhaust"
|
|
||||||
elif "memory" in name_lower or "dimm" in name_lower:
|
|
||||||
return "memory"
|
|
||||||
return "other"
|
|
||||||
|
|
||||||
def is_healthy(self) -> bool:
|
|
||||||
"""Check if controller is working properly."""
|
|
||||||
return self.consecutive_failures < self.max_failures
|
|
||||||
|
|
||||||
|
|
||||||
class IPMIControllerService:
|
|
||||||
"""Main service for IPMI Controller with all advanced features."""
|
|
||||||
|
|
||||||
def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"):
|
|
||||||
self.config_path = config_path
|
|
||||||
self.controller: Optional[IPMIFanController] = None
|
|
||||||
self.http_client: Optional[HTTPSensorClient] = None
|
|
||||||
self.running = False
|
|
||||||
self.thread: Optional[threading.Thread] = None
|
|
||||||
self.current_speeds: Dict[str, int] = {} # fan_id -> speed
|
|
||||||
self.target_speeds: Dict[str, int] = {}
|
|
||||||
self.last_temps: List[TemperatureReading] = []
|
|
||||||
self.last_fans: List[FanReading] = []
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
self.in_identify_mode = False
|
|
||||||
|
|
||||||
# Default config
|
|
||||||
self.config = {
|
|
||||||
# IPMI Settings
|
|
||||||
"ipmi_host": "",
|
|
||||||
"ipmi_username": "",
|
|
||||||
"ipmi_password": "",
|
|
||||||
"ipmi_port": 623,
|
|
||||||
|
|
||||||
# HTTP Sensor Settings
|
|
||||||
"http_sensor_enabled": False,
|
|
||||||
"http_sensor_url": "",
|
|
||||||
"http_sensor_timeout": 10,
|
|
||||||
|
|
||||||
# Fan Control Settings
|
|
||||||
"enabled": False,
|
|
||||||
"poll_interval": 10,
|
|
||||||
"fan_update_interval": 10,
|
|
||||||
"min_speed": 10,
|
|
||||||
"max_speed": 100,
|
|
||||||
"panic_temp": 85,
|
|
||||||
"panic_speed": 100,
|
|
||||||
"panic_on_no_data": True,
|
|
||||||
"no_data_timeout": 60,
|
|
||||||
|
|
||||||
# Sensor Selection
|
|
||||||
"primary_sensor": "cpu", # cpu, cpu1, cpu2, inlet, exhaust, pcie, etc.
|
|
||||||
"sensor_preference": "auto", # ipmi, http, auto
|
|
||||||
|
|
||||||
# Fan Configuration
|
|
||||||
"fans": {}, # fan_id -> {"name": "Custom Name", "group": "group1"}
|
|
||||||
"fan_groups": {}, # group_name -> {"fans": ["0x00", "0x01"], "curve": "Default"}
|
|
||||||
|
|
||||||
# Fan Curves
|
|
||||||
"active_curve": "Balanced",
|
|
||||||
"fan_curves": {
|
|
||||||
"Balanced": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 10},
|
|
||||||
{"temp": 35, "speed": 12},
|
|
||||||
{"temp": 40, "speed": 15},
|
|
||||||
{"temp": 45, "speed": 20},
|
|
||||||
{"temp": 50, "speed": 30},
|
|
||||||
{"temp": 55, "speed": 40},
|
|
||||||
{"temp": 60, "speed": 55},
|
|
||||||
{"temp": 65, "speed": 70},
|
|
||||||
{"temp": 70, "speed": 85},
|
|
||||||
{"temp": 75, "speed": 95},
|
|
||||||
{"temp": 80, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Silent": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 5},
|
|
||||||
{"temp": 40, "speed": 10},
|
|
||||||
{"temp": 50, "speed": 15},
|
|
||||||
{"temp": 55, "speed": 25},
|
|
||||||
{"temp": 60, "speed": 35},
|
|
||||||
{"temp": 65, "speed": 50},
|
|
||||||
{"temp": 70, "speed": 70},
|
|
||||||
{"temp": 75, "speed": 85},
|
|
||||||
{"temp": 80, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Performance": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 20},
|
|
||||||
{"temp": 35, "speed": 25},
|
|
||||||
{"temp": 40, "speed": 35},
|
|
||||||
{"temp": 45, "speed": 45},
|
|
||||||
{"temp": 50, "speed": 55},
|
|
||||||
{"temp": 55, "speed": 70},
|
|
||||||
{"temp": 60, "speed": 85},
|
|
||||||
{"temp": 65, "speed": 95},
|
|
||||||
{"temp": 70, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
# UI Settings
|
|
||||||
"theme": "dark", # dark, light, auto
|
|
||||||
}
|
|
||||||
|
|
||||||
self._load_config()
|
|
||||||
self._last_data_time = datetime.utcnow()
|
|
||||||
|
|
||||||
def _load_config(self):
|
|
||||||
"""Load configuration from file."""
|
|
||||||
try:
|
|
||||||
config_file = Path(self.config_path)
|
|
||||||
if config_file.exists():
|
|
||||||
with open(config_file) as f:
|
|
||||||
loaded = json.load(f)
|
|
||||||
self._deep_update(self.config, loaded)
|
|
||||||
logger.info(f"Loaded config from {self.config_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load config: {e}")
|
|
||||||
|
|
||||||
def _deep_update(self, d: dict, u: dict):
|
|
||||||
"""Deep update dictionary."""
|
|
||||||
for k, v in u.items():
|
|
||||||
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
|
|
||||||
self._deep_update(d[k], v)
|
|
||||||
else:
|
|
||||||
d[k] = v
|
|
||||||
|
|
||||||
def _save_config(self):
|
|
||||||
"""Save configuration to file."""
|
|
||||||
try:
|
|
||||||
config_file = Path(self.config_path)
|
|
||||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(config_file, 'w') as f:
|
|
||||||
json.dump(self.config, f, indent=2)
|
|
||||||
logger.info(f"Saved config to {self.config_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save config: {e}")
|
|
||||||
|
|
||||||
def update_config(self, **kwargs):
|
|
||||||
"""Update configuration values."""
|
|
||||||
self._deep_update(self.config, kwargs)
|
|
||||||
self._save_config()
|
|
||||||
|
|
||||||
# Reinitialize if needed
|
|
||||||
if any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']):
|
|
||||||
self._init_controller()
|
|
||||||
if any(k in kwargs for k in ['http_sensor_enabled', 'http_sensor_url']):
|
|
||||||
self._init_http_client()
|
|
||||||
|
|
||||||
def _init_controller(self) -> bool:
|
|
||||||
"""Initialize the IPMI controller."""
|
|
||||||
if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.controller = IPMIFanController(
|
|
||||||
host=self.config['ipmi_host'],
|
|
||||||
username=self.config['ipmi_username'],
|
|
||||||
password=self.config.get('ipmi_password', ''),
|
|
||||||
port=self.config.get('ipmi_port', 623)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.controller.test_connection():
|
|
||||||
logger.info(f"Connected to IPMI at {self.config['ipmi_host']}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to connect to IPMI")
|
|
||||||
self.controller = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _init_http_client(self) -> bool:
|
|
||||||
"""Initialize HTTP sensor client."""
|
|
||||||
if not self.config.get('http_sensor_enabled'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
url = self.config.get('http_sensor_url')
|
|
||||||
if not url:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.http_client = HTTPSensorClient(
|
|
||||||
url=url,
|
|
||||||
timeout=self.config.get('http_sensor_timeout', 10)
|
|
||||||
)
|
|
||||||
logger.info(f"HTTP sensor client initialized for {url}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def start(self) -> bool:
|
|
||||||
"""Start the controller service."""
|
|
||||||
if self.running:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not self._init_controller():
|
|
||||||
logger.error("Cannot start - IPMI connection failed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.config.get('http_sensor_enabled'):
|
|
||||||
self._init_http_client()
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
self.thread = threading.Thread(target=self._control_loop, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
logger.info("IPMI Controller service started")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the controller service."""
|
|
||||||
self.running = False
|
|
||||||
if self.thread:
|
|
||||||
self.thread.join(timeout=5)
|
|
||||||
|
|
||||||
if self.controller:
|
|
||||||
self.controller.disable_manual_fan_control()
|
|
||||||
|
|
||||||
logger.info("IPMI Controller service stopped")
|
|
||||||
|
|
||||||
def _control_loop(self):
|
|
||||||
"""Main control loop."""
|
|
||||||
if self.controller:
|
|
||||||
self.controller.enable_manual_fan_control()
|
|
||||||
|
|
||||||
poll_counter = 0
|
|
||||||
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
if not self.config.get('enabled', False):
|
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ensure controller is healthy
|
|
||||||
if not self.controller or not self.controller.is_healthy():
|
|
||||||
logger.warning("IPMI unhealthy, reconnecting...")
|
|
||||||
if not self._init_controller():
|
|
||||||
time.sleep(30)
|
|
||||||
continue
|
|
||||||
self.controller.enable_manual_fan_control()
|
|
||||||
|
|
||||||
# Poll temperatures at configured interval
|
|
||||||
poll_interval = self.config.get('poll_interval', 10)
|
|
||||||
if poll_counter % poll_interval == 0:
|
|
||||||
temps = self._get_temperatures()
|
|
||||||
fans = self.controller.get_fan_speeds() if self.controller else []
|
|
||||||
|
|
||||||
with self.lock:
|
|
||||||
self.last_temps = temps
|
|
||||||
self.last_fans = fans
|
|
||||||
|
|
||||||
if temps:
|
|
||||||
self._last_data_time = datetime.utcnow()
|
|
||||||
|
|
||||||
# Apply fan curves
|
|
||||||
if not self.in_identify_mode:
|
|
||||||
self._apply_fan_curves(temps)
|
|
||||||
|
|
||||||
poll_counter += 1
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Control loop error: {e}")
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
def _get_temperatures(self) -> List[TemperatureReading]:
|
|
||||||
"""Get temperatures from all sources."""
|
|
||||||
temps = []
|
|
||||||
preference = self.config.get('sensor_preference', 'ipmi')
|
|
||||||
|
|
||||||
# Try IPMI
|
|
||||||
if self.controller and preference in ['ipmi', 'auto']:
|
|
||||||
temps = self.controller.get_temperatures()
|
|
||||||
|
|
||||||
# Try HTTP sensor
|
|
||||||
if self.http_client and preference in ['http', 'auto']:
|
|
||||||
http_temps = self.http_client.fetch_sensors()
|
|
||||||
if http_temps:
|
|
||||||
if preference == 'http' or not temps:
|
|
||||||
temps = http_temps
|
|
||||||
else:
|
|
||||||
# Merge, preferring HTTP for PCIe sensors
|
|
||||||
temp_dict = {t.name: t for t in temps}
|
|
||||||
for ht in http_temps:
|
|
||||||
if ht.location == 'pcie' or ht.name not in temp_dict:
|
|
||||||
temps.append(ht)
|
|
||||||
|
|
||||||
return temps
|
|
||||||
|
|
||||||
def _apply_fan_curves(self, temps: List[TemperatureReading]):
|
|
||||||
"""Apply fan curves based on temperatures."""
|
|
||||||
if not temps:
|
|
||||||
# Check for panic mode on no data
|
|
||||||
if self.config.get('panic_on_no_data', True):
|
|
||||||
time_since_data = (datetime.utcnow() - self._last_data_time).total_seconds()
|
|
||||||
if time_since_data > self.config.get('no_data_timeout', 60):
|
|
||||||
self._set_all_fans(self.config.get('panic_speed', 100), "PANIC: No data")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get primary sensor
|
|
||||||
primary_sensor = self.config.get('primary_sensor', 'cpu')
|
|
||||||
sensor_temps = [t for t in temps if t.location == primary_sensor]
|
|
||||||
if not sensor_temps:
|
|
||||||
sensor_temps = [t for t in temps if t.location.startswith(primary_sensor)]
|
|
||||||
if not sensor_temps:
|
|
||||||
sensor_temps = temps # Fallback to any temp
|
|
||||||
|
|
||||||
max_temp = max(t.value for t in sensor_temps)
|
|
||||||
|
|
||||||
# Check panic temperature
|
|
||||||
if max_temp >= self.config.get('panic_temp', 85):
|
|
||||||
self._set_all_fans(self.config.get('panic_speed', 100), f"PANIC: Temp {max_temp}°C")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get fan curves
|
|
||||||
curves = self.config.get('fan_curves', {})
|
|
||||||
active_curve_name = self.config.get('active_curve', 'Balanced')
|
|
||||||
default_curve = curves.get(active_curve_name, curves.get('Balanced', {'points': [{'temp': 30, 'speed': 15}, {'temp': 80, 'speed': 100}]}))
|
|
||||||
|
|
||||||
# Apply curves to fans
|
|
||||||
fans = self.config.get('fans', {})
|
|
||||||
groups = self.config.get('fan_groups', {})
|
|
||||||
|
|
||||||
# Calculate target speeds per group/individual
|
|
||||||
fan_speeds = {}
|
|
||||||
|
|
||||||
for fan_id, fan_info in fans.items():
|
|
||||||
group = fan_info.get('group')
|
|
||||||
curve_name = fan_info.get('curve', 'Default')
|
|
||||||
|
|
||||||
if group and group in groups:
|
|
||||||
curve_name = groups[group].get('curve', 'Default')
|
|
||||||
|
|
||||||
curve = curves.get(curve_name, default_curve)
|
|
||||||
speed = self._calculate_curve_speed(max_temp, curve['points'])
|
|
||||||
|
|
||||||
# Apply limits
|
|
||||||
speed = max(self.config.get('min_speed', 10),
|
|
||||||
min(self.config.get('max_speed', 100), speed))
|
|
||||||
|
|
||||||
fan_speeds[fan_id] = speed
|
|
||||||
|
|
||||||
# If no individual fan configs, apply to all
|
|
||||||
if not fan_speeds:
|
|
||||||
speed = self._calculate_curve_speed(max_temp, default_curve['points'])
|
|
||||||
speed = max(self.config.get('min_speed', 10),
|
|
||||||
min(self.config.get('max_speed', 100), speed))
|
|
||||||
self._set_all_fans(speed, f"Temp {max_temp}°C")
|
|
||||||
else:
|
|
||||||
# Set individual fan speeds
|
|
||||||
for fan_id, speed in fan_speeds.items():
|
|
||||||
self._set_fan_speed(fan_id, speed, f"Temp {max_temp}°C")
|
|
||||||
|
|
||||||
def _calculate_curve_speed(self, temp: float, points: List[Dict]) -> int:
|
|
||||||
"""Calculate fan speed from curve points."""
|
|
||||||
if not points:
|
|
||||||
return 50
|
|
||||||
|
|
||||||
sorted_points = sorted(points, key=lambda p: p['temp'])
|
|
||||||
|
|
||||||
if temp <= sorted_points[0]['temp']:
|
|
||||||
return sorted_points[0]['speed']
|
|
||||||
if temp >= sorted_points[-1]['temp']:
|
|
||||||
return sorted_points[-1]['speed']
|
|
||||||
|
|
||||||
for i in range(len(sorted_points) - 1):
|
|
||||||
p1, p2 = sorted_points[i], sorted_points[i + 1]
|
|
||||||
if p1['temp'] <= temp <= p2['temp']:
|
|
||||||
if p2['temp'] == p1['temp']:
|
|
||||||
return p1['speed']
|
|
||||||
ratio = (temp - p1['temp']) / (p2['temp'] - p1['temp'])
|
|
||||||
speed = p1['speed'] + ratio * (p2['speed'] - p1['speed'])
|
|
||||||
return int(round(speed))
|
|
||||||
|
|
||||||
return sorted_points[-1]['speed']
|
|
||||||
|
|
||||||
def _set_all_fans(self, speed: int, reason: str):
|
|
||||||
"""Set all fans to a speed."""
|
|
||||||
if self.controller and speed != self.current_speeds.get('all'):
|
|
||||||
if self.controller.set_fan_speed(speed, "0xff"):
|
|
||||||
self.current_speeds['all'] = speed
|
|
||||||
logger.info(f"All fans set to {speed}% ({reason})")
|
|
||||||
|
|
||||||
def _set_fan_speed(self, fan_id: str, speed: int, reason: str):
|
|
||||||
"""Set specific fan speed."""
|
|
||||||
if self.controller and speed != self.current_speeds.get(fan_id):
|
|
||||||
if self.controller.set_fan_speed(speed, fan_id):
|
|
||||||
self.current_speeds[fan_id] = speed
|
|
||||||
logger.info(f"Fan {fan_id} set to {speed}% ({reason})")
|
|
||||||
|
|
||||||
def identify_fan(self, fan_id: str):
|
|
||||||
"""Identify a fan by setting it to 100% and others to 0%."""
|
|
||||||
if not self.controller:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.in_identify_mode = True
|
|
||||||
|
|
||||||
# Set all fans to 0%
|
|
||||||
self.controller.set_fan_speed(0, "0xff")
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# Set target fan to 100%
|
|
||||||
self.controller.set_fan_speed(100, fan_id)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def stop_identify(self):
|
|
||||||
"""Stop identify mode and resume normal control."""
|
|
||||||
self.in_identify_mode = False
|
|
||||||
|
|
||||||
def set_manual_speed(self, speed: int, fan_id: str = "0xff") -> bool:
|
|
||||||
"""Set manual fan speed."""
|
|
||||||
if not self.controller:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.config['enabled'] = False
|
|
||||||
self._save_config()
|
|
||||||
speed = max(0, min(100, speed))
|
|
||||||
|
|
||||||
return self.controller.set_fan_speed(speed, fan_id)
|
|
||||||
|
|
||||||
def set_auto_mode(self, enabled: bool):
|
|
||||||
"""Enable or disable automatic control."""
|
|
||||||
self.config['enabled'] = enabled
|
|
||||||
self._save_config()
|
|
||||||
|
|
||||||
if enabled and self.controller:
|
|
||||||
self.controller.enable_manual_fan_control()
|
|
||||||
elif not enabled and self.controller:
|
|
||||||
self.controller.disable_manual_fan_control()
|
|
||||||
|
|
||||||
def get_status(self) -> Dict:
|
|
||||||
"""Get current controller status."""
|
|
||||||
with self.lock:
|
|
||||||
status = {
|
|
||||||
"running": self.running,
|
|
||||||
"enabled": self.config.get('enabled', False),
|
|
||||||
"connected": self.controller is not None and self.controller.is_healthy(),
|
|
||||||
"manual_mode": self.controller.manual_mode if self.controller else False,
|
|
||||||
"in_identify_mode": self.in_identify_mode,
|
|
||||||
"current_speeds": self.current_speeds,
|
|
||||||
"target_speeds": self.target_speeds,
|
|
||||||
"temperatures": [asdict(t) for t in self.last_temps],
|
|
||||||
"fans": [asdict(f) for f in self.last_fans],
|
|
||||||
"config": self._get_safe_config()
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
|
|
||||||
def _get_safe_config(self) -> Dict:
|
|
||||||
"""Get config without sensitive data."""
|
|
||||||
safe = json.loads(json.dumps(self.config))
|
|
||||||
# Remove passwords
|
|
||||||
safe.pop('ipmi_password', None)
|
|
||||||
safe.pop('http_sensor_password', None)
|
|
||||||
return safe
|
|
||||||
|
|
||||||
|
|
||||||
# Global service instances
|
|
||||||
_service_instances: Dict[str, IPMIControllerService] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_service(config_path: str = "/etc/ipmi-controller/config.json") -> IPMIControllerService:
|
|
||||||
"""Get or create the service instance."""
|
|
||||||
if config_path not in _service_instances:
|
|
||||||
_service_instances[config_path] = IPMIControllerService(config_path)
|
|
||||||
return _service_instances[config_path]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# CLI test
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if len(sys.argv) < 4:
|
|
||||||
print("Usage: fan_controller.py <host> <username> <password>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
host, user, pwd = sys.argv[1:4]
|
|
||||||
port = int(sys.argv[4]) if len(sys.argv) > 4 else 623
|
|
||||||
|
|
||||||
ctrl = IPMIFanController(host, user, pwd, port)
|
|
||||||
|
|
||||||
print(f"Testing {host}...")
|
|
||||||
if ctrl.test_connection():
|
|
||||||
print("✓ Connected")
|
|
||||||
print("\nTemps:", [(t.name, t.value) for t in ctrl.get_temperatures()])
|
|
||||||
print("\nFans:", [(f.fan_number, f.speed_rpm) for f in ctrl.get_fan_speeds()])
|
|
||||||
else:
|
|
||||||
print("✗ Failed")
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>IPMI Fan Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "ipmi-fan-control-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.3",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/icons-material": "^5.15.5",
|
||||||
|
"@mui/material": "^5.15.5",
|
||||||
|
"@mui/x-charts": "^6.18.7",
|
||||||
|
"@tanstack/react-query": "^5.17.15",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.2",
|
||||||
|
"recharts": "^2.10.4",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.302-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { setupApi } from './utils/api';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import SetupWizard from './pages/SetupWizard';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import ServerList from './pages/ServerList';
|
||||||
|
import ServerDetail from './pages/ServerDetail';
|
||||||
|
import FanCurves from './pages/FanCurves';
|
||||||
|
import Logs from './pages/Logs';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated, fetchUser, token } = useAuthStore();
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Check if setup is complete
|
||||||
|
const { data: setupStatus, isLoading: isSetupLoading } = useQuery({
|
||||||
|
queryKey: ['setup-status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await setupApi.getStatus();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check auth status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
if (token) {
|
||||||
|
await fetchUser();
|
||||||
|
}
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
}, [token, fetchUser]);
|
||||||
|
|
||||||
|
if (isSetupLoading || isCheckingAuth) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setup is not complete, show setup wizard
|
||||||
|
if (!setupStatus?.setup_complete) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<SetupWizard />} />
|
||||||
|
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not authenticated, show login
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace state={{ from: location }} />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated routes
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/servers" element={<ServerList />} />
|
||||||
|
<Route path="/servers/:id" element={<ServerDetail />} />
|
||||||
|
<Route path="/servers/:id/curves" element={<FanCurves />} />
|
||||||
|
<Route path="/logs" element={<Logs />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,541 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemButton,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
PlayArrow as PlayIcon,
|
||||||
|
Stop as StopIcon,
|
||||||
|
ShowChart as ChartIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { fanCurvesApi, fanControlApi } from '../utils/api';
|
||||||
|
import type { FanCurve, FanCurvePoint, Server } from '../types';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
interface FanCurveManagerProps {
|
||||||
|
serverId: number;
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FanCurveManager({ serverId, server }: FanCurveManagerProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null);
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
sensor_source: 'cpu',
|
||||||
|
points: [
|
||||||
|
{ temp: 30, speed: 10 },
|
||||||
|
{ temp: 40, speed: 20 },
|
||||||
|
{ temp: 50, speed: 35 },
|
||||||
|
{ temp: 60, speed: 50 },
|
||||||
|
{ temp: 70, speed: 70 },
|
||||||
|
{ temp: 80, speed: 100 },
|
||||||
|
] as FanCurvePoint[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: curves, isLoading } = useQuery({
|
||||||
|
queryKey: ['fan-curves', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fanCurvesApi.getAll(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; curve_data: FanCurvePoint[]; sensor_source: string; is_active: boolean }) =>
|
||||||
|
fanCurvesApi.create(serverId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setError(error.response?.data?.detail || 'Failed to create fan curve');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
|
||||||
|
fanCurvesApi.update(serverId, curveId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setError(error.response?.data?.detail || 'Failed to update fan curve');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (curveId: number) => fanCurvesApi.delete(serverId, curveId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
if (selectedCurve?.id) {
|
||||||
|
setSelectedCurve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableAutoMutation = useMutation({
|
||||||
|
mutationFn: (curveId: number) =>
|
||||||
|
fanControlApi.enableAuto(serverId, { enabled: true, curve_id: curveId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableAutoMutation = useMutation({
|
||||||
|
mutationFn: () => fanControlApi.disableAuto(serverId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenDialog = (curve?: FanCurve) => {
|
||||||
|
setError('');
|
||||||
|
if (curve) {
|
||||||
|
setEditingCurve(curve);
|
||||||
|
setFormData({
|
||||||
|
name: curve.name,
|
||||||
|
sensor_source: curve.sensor_source,
|
||||||
|
points: curve.curve_data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingCurve(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
sensor_source: 'cpu',
|
||||||
|
points: [
|
||||||
|
{ temp: 30, speed: 10 },
|
||||||
|
{ temp: 40, speed: 20 },
|
||||||
|
{ temp: 50, speed: 35 },
|
||||||
|
{ temp: 60, speed: 50 },
|
||||||
|
{ temp: 70, speed: 70 },
|
||||||
|
{ temp: 80, speed: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingCurve(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
setError('Curve name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.points.length < 2) {
|
||||||
|
setError('At least 2 points are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const point of formData.points) {
|
||||||
|
if (point.speed < 0 || point.speed > 100) {
|
||||||
|
setError('Fan speed must be between 0 and 100');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (point.temp < 0 || point.temp > 150) {
|
||||||
|
setError('Temperature must be between 0 and 150');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
curve_data: formData.points,
|
||||||
|
sensor_source: formData.sensor_source,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCurve) {
|
||||||
|
updateMutation.mutate({ curveId: editingCurve.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePoint = (index: number, field: keyof FanCurvePoint, value: number) => {
|
||||||
|
const newPoints = [...formData.points];
|
||||||
|
newPoints[index] = { ...newPoints[index], [field]: value };
|
||||||
|
setFormData({ ...formData, points: newPoints });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPoint = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
points: [...formData.points, { temp: 50, speed: 50 }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePoint = (index: number) => {
|
||||||
|
if (formData.points.length > 2) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
points: formData.points.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActiveCurve = (curve: FanCurve) => {
|
||||||
|
return server.auto_control_enabled && selectedCurve?.id === curve.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
<ChartIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Fan Curves
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{server.auto_control_enabled ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
startIcon={<StopIcon />}
|
||||||
|
onClick={() => disableAutoMutation.mutate()}
|
||||||
|
>
|
||||||
|
Stop Auto
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PlayIcon />}
|
||||||
|
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
|
||||||
|
disabled={!selectedCurve}
|
||||||
|
>
|
||||||
|
Start Auto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
>
|
||||||
|
New Curve
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{server.auto_control_enabled && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
Automatic fan control is active
|
||||||
|
{selectedCurve && ` - Using "${selectedCurve.name}"`}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{/* Curve List */}
|
||||||
|
<Grid item xs={12} md={5}>
|
||||||
|
<Paper variant="outlined">
|
||||||
|
<List dense>
|
||||||
|
{isLoading ? (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Loading..." />
|
||||||
|
</ListItem>
|
||||||
|
) : curves?.length === 0 ? (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="No fan curves"
|
||||||
|
secondary="Create a curve to enable automatic fan control"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
curves?.map((curve) => (
|
||||||
|
<ListItem
|
||||||
|
key={curve.id}
|
||||||
|
secondaryAction={
|
||||||
|
<Box>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenDialog(curve);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Delete this fan curve?')) {
|
||||||
|
deleteMutation.mutate(curve.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selectedCurve?.id === curve.id}
|
||||||
|
onClick={() => setSelectedCurve(curve)}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{curve.name}
|
||||||
|
{isActiveCurve(curve) && (
|
||||||
|
<Chip size="small" color="success" label="Active" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<Chip size="small" label={curve.sensor_source} variant="outlined" />
|
||||||
|
<Chip size="small" label={`${curve.curve_data.length} points`} variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Curve Preview */}
|
||||||
|
<Grid item xs={12} md={7}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, height: 280 }}>
|
||||||
|
{selectedCurve ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
{selectedCurve.name}
|
||||||
|
</Typography>
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<LineChart
|
||||||
|
data={selectedCurve.curve_data.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: `${p.temp}°C`,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="temp"
|
||||||
|
label={{ value: 'Temp (°C)', position: 'insideBottom', offset: -5 }}
|
||||||
|
type="number"
|
||||||
|
domain={[0, 'dataMax + 10']}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
label={{ value: 'Fan %', angle: -90, position: 'insideLeft' }}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
name === 'speed' ? `${value}%` : `${value}°C`,
|
||||||
|
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="speed"
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 5 }}
|
||||||
|
activeDot={{ r: 7 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Select a fan curve to preview
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Curve Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
error={!formData.name && !!error}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Sensor Source</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.sensor_source}
|
||||||
|
label="Sensor Source"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, sensor_source: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="cpu">CPU Temperature</MenuItem>
|
||||||
|
<MenuItem value="inlet">Inlet/Ambient Temperature</MenuItem>
|
||||||
|
<MenuItem value="exhaust">Exhaust Temperature</MenuItem>
|
||||||
|
<MenuItem value="highest">Highest Temperature</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
|
||||||
|
Curve Points
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{formData.points.map((point, index) => (
|
||||||
|
<Grid item xs={12} key={index}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Temperature (°C)"
|
||||||
|
value={point.temp}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePoint(index, 'temp', parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
inputProps={{ min: 0, max: 150 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Fan Speed (%)"
|
||||||
|
value={point.speed}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removePoint(index)}
|
||||||
|
disabled={formData.points.length <= 2}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={addPoint}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
>
|
||||||
|
Add Point
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Preview Chart */}
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
Preview
|
||||||
|
</Typography>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, height: 200 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={formData.points}
|
||||||
|
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="temp" type="number" domain={[0, 'dataMax + 10']} />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
name === 'speed' ? `${value}%` : `${value}°C`,
|
||||||
|
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="speed"
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Paper>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingCurve ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Drawer,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Dns as ServerIcon,
|
||||||
|
Timeline as LogsIcon,
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Logout as LogoutIcon,
|
||||||
|
AccountCircle,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
|
||||||
|
{ text: 'Servers', icon: <ServerIcon />, path: '/servers' },
|
||||||
|
{ text: 'Logs', icon: <LogsIcon />, path: '/logs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileMenuClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
handleProfileMenuClose();
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawer = (
|
||||||
|
<div>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" noWrap component="div">
|
||||||
|
IPMI Fan Control
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(item.path);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
ml: { md: `${drawerWidth}px` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2, display: { md: 'none' } }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
{menuItems.find((item) => item.path === location.pathname)?.text || 'IPMI Fan Control'}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="menu-appbar"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleProfileMenuOpen}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<AccountCircle />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
id="menu-appbar"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleProfileMenuClose}
|
||||||
|
>
|
||||||
|
<MenuItem disabled>
|
||||||
|
<Typography variant="body2">{user?.username}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={handleLogout}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
|
||||||
|
>
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true,
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'block' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
mt: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#121212',
|
||||||
|
paper: '#1e1e1e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Skeleton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Dns as ServerIcon,
|
||||||
|
Speed as SpeedIcon,
|
||||||
|
Warning as WarningIcon,
|
||||||
|
Error as ErrorIcon,
|
||||||
|
CheckCircle as CheckIcon,
|
||||||
|
Thermostat as TempIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
PowerSettingsNew as PowerIcon,
|
||||||
|
Memory as MemoryIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { dashboardApi } from '../utils/api';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface ServerOverview {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
is_active: boolean;
|
||||||
|
manual_control_enabled: boolean;
|
||||||
|
auto_control_enabled: boolean;
|
||||||
|
max_temp: number | null;
|
||||||
|
avg_fan_speed: number | null;
|
||||||
|
power_consumption: number | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
cached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Stats query - poll every 60 seconds (stats don't change often)
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await dashboardApi.getStats();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // 60 seconds
|
||||||
|
staleTime: 55000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server overview query - poll every 30 seconds (matches sensor collector)
|
||||||
|
const { data: overviewData, isLoading: overviewLoading } = useQuery({
|
||||||
|
queryKey: ['servers-overview'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await dashboardApi.getServersOverview();
|
||||||
|
return response.data.servers as ServerOverview[];
|
||||||
|
},
|
||||||
|
refetchInterval: 30000, // 30 seconds - matches sensor collector
|
||||||
|
staleTime: 25000,
|
||||||
|
// Don't refetch on window focus to reduce load
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background refresh mutation
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: async (serverId: number) => {
|
||||||
|
const response = await dashboardApi.refreshServer(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate overview after a short delay to allow background fetch
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers-overview'] });
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEventIcon = (eventType: string) => {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'panic':
|
||||||
|
return <ErrorIcon color="error" />;
|
||||||
|
case 'error':
|
||||||
|
return <WarningIcon color="warning" />;
|
||||||
|
case 'warning':
|
||||||
|
return <WarningIcon color="warning" />;
|
||||||
|
default:
|
||||||
|
return <CheckIcon color="success" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
}) => (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Box sx={{ color, mr: 1 }}>{icon}</Box>
|
||||||
|
<Typography variant="h6" component="div">
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ServerCard = ({ server }: { server: ServerOverview }) => {
|
||||||
|
const hasData = server.max_temp !== null || server.avg_fan_speed !== null;
|
||||||
|
const isLoading = !hasData && server.is_active;
|
||||||
|
|
||||||
|
const getTempColor = (temp: number | null) => {
|
||||||
|
if (temp === null) return 'text.secondary';
|
||||||
|
if (temp > 80) return 'error.main';
|
||||||
|
if (temp > 70) return 'warning.main';
|
||||||
|
return 'success.main';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = () => {
|
||||||
|
if (!server.is_active) {
|
||||||
|
return <Chip size="small" label="Offline" color="default" icon={<PowerIcon />} />;
|
||||||
|
}
|
||||||
|
if (server.manual_control_enabled) {
|
||||||
|
return <Chip size="small" label="Manual" color="info" icon={<SpeedIcon />} />;
|
||||||
|
}
|
||||||
|
if (server.auto_control_enabled) {
|
||||||
|
return <Chip size="small" label="Auto" color="success" icon={<CheckIcon />} />;
|
||||||
|
}
|
||||||
|
return <Chip size="small" label="Active" color="success" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: 2,
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/servers/${server.id}`)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<ServerIcon color={server.is_active ? 'primary' : 'disabled'} />
|
||||||
|
<Typography variant="subtitle1" fontWeight="medium" noWrap sx={{ maxWidth: 150 }}>
|
||||||
|
{server.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{getStatusChip()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metrics Grid - Always show values or -- placeholder */}
|
||||||
|
<Grid container spacing={1} sx={{ mb: 1 }}>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color={getTempColor(server.max_temp)}>
|
||||||
|
{server.max_temp !== null ? `${Math.round(server.max_temp)}°C` : '--'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Max Temp
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="primary.main">
|
||||||
|
{server.avg_fan_speed !== null ? `${Math.round(server.avg_fan_speed)}%` : '--'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Avg Fan
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.primary">
|
||||||
|
{server.power_consumption !== null ? `${Math.round(server.power_consumption)}W` : '--'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Power
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{server.vendor || 'Unknown Vendor'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Chip size="small" label="Loading..." color="warning" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
|
||||||
|
) : server.cached ? (
|
||||||
|
<Chip size="small" label="Cached" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
|
||||||
|
) : null}
|
||||||
|
<Tooltip title="Refresh data">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
refreshMutation.mutate(server.id);
|
||||||
|
}}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
>
|
||||||
|
<RefreshIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show placeholder cards while loading initial data
|
||||||
|
const ServersPlaceholderGrid = () => (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} lg={3} key={i}>
|
||||||
|
<Card variant="outlined" sx={{ opacity: 0.5 }}>
|
||||||
|
<CardContent sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={24} height={24} />
|
||||||
|
<Skeleton variant="text" width="60%" />
|
||||||
|
</Box>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
||||||
|
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
||||||
|
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
||||||
|
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
|
<StatCard
|
||||||
|
title="Total Servers"
|
||||||
|
value={stats?.total_servers || 0}
|
||||||
|
icon={<ServerIcon />}
|
||||||
|
color="primary.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
|
<StatCard
|
||||||
|
title="Active Servers"
|
||||||
|
value={stats?.active_servers || 0}
|
||||||
|
icon={<CheckIcon />}
|
||||||
|
color="success.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
|
<StatCard
|
||||||
|
title="Manual Control"
|
||||||
|
value={stats?.manual_control_servers || 0}
|
||||||
|
icon={<SpeedIcon />}
|
||||||
|
color="info.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
|
<StatCard
|
||||||
|
title="Auto Control"
|
||||||
|
value={stats?.auto_control_servers || 0}
|
||||||
|
icon={<TempIcon />}
|
||||||
|
color="warning.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
|
<StatCard
|
||||||
|
title="Panic Mode"
|
||||||
|
value={stats?.panic_mode_servers || 0}
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
color="error.main"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Servers Grid */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
Server Overview
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${overviewData?.length || 0} servers`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{overviewLoading ? (
|
||||||
|
<ServersPlaceholderGrid />
|
||||||
|
) : overviewData && overviewData.length > 0 ? (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{overviewData.map((server) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} lg={3} key={server.id}>
|
||||||
|
<ServerCard server={server} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<ServerIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
No servers configured
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Add your first server to start monitoring
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label="Add Server"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate('/servers')}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Recent Events</Typography>
|
||||||
|
<Chip
|
||||||
|
label="View All"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate('/logs')}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<List dense>
|
||||||
|
{stats?.recent_logs?.slice(0, 10).map((log: any) => (
|
||||||
|
<ListItem key={log.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{getEventIcon(log.event_type)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={log.message}
|
||||||
|
secondary={new Date(log.timestamp).toLocaleString()}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{!stats?.recent_logs?.length && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="No events yet"
|
||||||
|
secondary="Events will appear here when they occur"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
About IPMI Fan Control
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
This application allows you to control fan speeds on Dell T710 and compatible servers
|
||||||
|
using IPMI commands. Features include:
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon><SpeedIcon color="primary" fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText primary="Manual fan control with per-fan adjustment" />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon><TempIcon color="primary" fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText primary="Automatic fan curves based on temperature sensors" />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon><MemoryIcon color="primary" fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText primary="SSH-based CPU temperature monitoring" />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon><ErrorIcon color="primary" fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText primary="Safety panic mode for overheating protection" />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemButton,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
PlayArrow as PlayIcon,
|
||||||
|
Stop as StopIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api';
|
||||||
|
import type { FanCurve, FanCurvePoint } from '../types';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
export default function FanCurves() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const serverId = parseInt(id || '0');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null);
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
sensor_source: 'cpu',
|
||||||
|
points: [
|
||||||
|
{ temp: 30, speed: 10 },
|
||||||
|
{ temp: 40, speed: 20 },
|
||||||
|
{ temp: 50, speed: 35 },
|
||||||
|
{ temp: 60, speed: 50 },
|
||||||
|
{ temp: 70, speed: 70 },
|
||||||
|
{ temp: 80, speed: 100 },
|
||||||
|
] as FanCurvePoint[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: server } = useQuery({
|
||||||
|
queryKey: ['server', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await serversApi.getById(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: curves } = useQuery({
|
||||||
|
queryKey: ['fan-curves', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fanCurvesApi.getAll(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; curve_data: FanCurvePoint[]; sensor_source: string; is_active: boolean }) =>
|
||||||
|
fanCurvesApi.create(serverId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setError(error.response?.data?.detail || 'Failed to create fan curve');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
|
||||||
|
fanCurvesApi.update(serverId, curveId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setError(error.response?.data?.detail || 'Failed to update fan curve');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (curveId: number) => fanCurvesApi.delete(serverId, curveId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
||||||
|
if (selectedCurve?.id) {
|
||||||
|
setSelectedCurve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableAutoMutation = useMutation({
|
||||||
|
mutationFn: (curveId: number) =>
|
||||||
|
fanControlApi.enableAuto(serverId, { enabled: true, curve_id: curveId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableAutoMutation = useMutation({
|
||||||
|
mutationFn: () => fanControlApi.disableAuto(serverId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenDialog = (curve?: FanCurve) => {
|
||||||
|
setError('');
|
||||||
|
if (curve) {
|
||||||
|
setEditingCurve(curve);
|
||||||
|
setFormData({
|
||||||
|
name: curve.name,
|
||||||
|
sensor_source: curve.sensor_source,
|
||||||
|
points: curve.curve_data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingCurve(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
sensor_source: 'cpu',
|
||||||
|
points: [
|
||||||
|
{ temp: 30, speed: 10 },
|
||||||
|
{ temp: 40, speed: 20 },
|
||||||
|
{ temp: 50, speed: 35 },
|
||||||
|
{ temp: 60, speed: 50 },
|
||||||
|
{ temp: 70, speed: 70 },
|
||||||
|
{ temp: 80, speed: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingCurve(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
setError('Curve name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.points.length < 2) {
|
||||||
|
setError('At least 2 points are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate points
|
||||||
|
for (const point of formData.points) {
|
||||||
|
if (point.speed < 0 || point.speed > 100) {
|
||||||
|
setError('Fan speed must be between 0 and 100');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (point.temp < 0 || point.temp > 150) {
|
||||||
|
setError('Temperature must be between 0 and 150');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
curve_data: formData.points,
|
||||||
|
sensor_source: formData.sensor_source,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCurve) {
|
||||||
|
updateMutation.mutate({ curveId: editingCurve.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePoint = (index: number, field: keyof FanCurvePoint, value: number) => {
|
||||||
|
const newPoints = [...formData.points];
|
||||||
|
newPoints[index] = { ...newPoints[index], [field]: value };
|
||||||
|
setFormData({ ...formData, points: newPoints });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPoint = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
points: [...formData.points, { temp: 50, speed: 50 }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePoint = (index: number) => {
|
||||||
|
if (formData.points.length > 2) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
points: formData.points.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4">Fan Curves</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{server?.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{server?.auto_control_enabled ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<StopIcon />}
|
||||||
|
onClick={() => disableAutoMutation.mutate()}
|
||||||
|
>
|
||||||
|
Stop Auto Control
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PlayIcon />}
|
||||||
|
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
|
||||||
|
disabled={!selectedCurve}
|
||||||
|
>
|
||||||
|
Start Auto Control
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
>
|
||||||
|
New Curve
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{server?.auto_control_enabled && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Automatic fan control is currently active on this server.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Curve List */}
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<Paper>
|
||||||
|
<List>
|
||||||
|
{curves?.map((curve) => (
|
||||||
|
<ListItem
|
||||||
|
key={curve.id}
|
||||||
|
secondaryAction={
|
||||||
|
<Box>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenDialog(curve);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Delete this fan curve?')) {
|
||||||
|
deleteMutation.mutate(curve.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selectedCurve?.id === curve.id}
|
||||||
|
onClick={() => setSelectedCurve(curve)}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={curve.name}
|
||||||
|
secondary={
|
||||||
|
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<Chip size="small" label={curve.sensor_source} />
|
||||||
|
{server?.auto_control_enabled && selectedCurve?.id === curve.id && (
|
||||||
|
<Chip size="small" color="success" label="Active" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{!curves?.length && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="No fan curves yet"
|
||||||
|
secondary="Create a new curve to get started"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Curve Preview */}
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Paper sx={{ p: 3, height: 400 }}>
|
||||||
|
{selectedCurve ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{selectedCurve.name}
|
||||||
|
</Typography>
|
||||||
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
|
<LineChart
|
||||||
|
data={selectedCurve.curve_data.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: `${p.temp}°C`,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="temp"
|
||||||
|
label={{ value: 'Temperature (°C)', position: 'insideBottom', offset: -5 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
label={{ value: 'Fan Speed (%)', angle: -90, position: 'insideLeft' }}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
name === 'speed' ? `${value}%` : `${value}°C`,
|
||||||
|
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="speed"
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 6 }}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Select a fan curve to preview
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Curve Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
error={!formData.name && !!error}
|
||||||
|
helperText={!formData.name && error ? 'Name is required' : 'Enter a name for this fan curve'}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Sensor Source</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.sensor_source}
|
||||||
|
label="Sensor Source"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, sensor_source: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="cpu">CPU Temperature</MenuItem>
|
||||||
|
<MenuItem value="inlet">Inlet/Ambient Temperature</MenuItem>
|
||||||
|
<MenuItem value="exhaust">Exhaust Temperature</MenuItem>
|
||||||
|
<MenuItem value="highest">Highest Temperature</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
|
||||||
|
Curve Points
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{formData.points.map((point, index) => (
|
||||||
|
<Grid item xs={12} key={index}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Temperature (°C)"
|
||||||
|
value={point.temp}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePoint(index, 'temp', parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
inputProps={{ min: 0, max: 150 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Fan Speed (%)"
|
||||||
|
value={point.speed}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removePoint(index)}
|
||||||
|
disabled={formData.points.length <= 2}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button variant="outlined" onClick={addPoint} sx={{ mt: 2 }}>
|
||||||
|
Add Point
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Preview
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ height: 250 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={formData.points}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="temp" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="speed"
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={isLoading || !formData.name.trim()}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : editingCurve ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login, error, clearError, isLoading } = useAuthStore();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const from = (location.state as any)?.from?.pathname || '/';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData.username, formData.password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ maxWidth: 400, width: '100%' }}>
|
||||||
|
<CardContent sx={{ p: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||||
|
IPMI Fan Control
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center" paragraph>
|
||||||
|
Sign in to manage your servers
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, username: e.target.value })
|
||||||
|
}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={isLoading}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Pagination,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Error as ErrorIcon,
|
||||||
|
Warning as WarningIcon,
|
||||||
|
CheckCircle as CheckIcon,
|
||||||
|
Info as InfoIcon,
|
||||||
|
Speed as SpeedIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { logsApi, serversApi } from '../utils/api';
|
||||||
|
|
||||||
|
|
||||||
|
const LOGS_PER_PAGE = 25;
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [eventType, setEventType] = useState<string>('');
|
||||||
|
const [serverFilter, setServerFilter] = useState<number | ''>('');
|
||||||
|
|
||||||
|
const { data: servers } = useQuery({
|
||||||
|
queryKey: ['servers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await serversApi.getAll();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: logs } = useQuery({
|
||||||
|
queryKey: ['logs', eventType, serverFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await logsApi.getAll({
|
||||||
|
event_type: eventType || undefined,
|
||||||
|
server_id: serverFilter || undefined,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEventIcon = (eventType: string) => {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'panic':
|
||||||
|
return <ErrorIcon color="error" />;
|
||||||
|
case 'error':
|
||||||
|
return <ErrorIcon color="error" />;
|
||||||
|
case 'warning':
|
||||||
|
return <WarningIcon color="warning" />;
|
||||||
|
case 'fan_change':
|
||||||
|
return <SpeedIcon color="info" />;
|
||||||
|
case 'info':
|
||||||
|
return <InfoIcon color="info" />;
|
||||||
|
default:
|
||||||
|
return <CheckIcon color="success" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventColor = (eventType: string) => {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'panic':
|
||||||
|
return 'error';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
case 'fan_change':
|
||||||
|
return 'info';
|
||||||
|
case 'info':
|
||||||
|
return 'default';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil((logs?.length || 0) / LOGS_PER_PAGE);
|
||||||
|
const paginatedLogs = logs?.slice((page - 1) * LOGS_PER_PAGE, page * LOGS_PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
System Logs
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<FormControl sx={{ minWidth: 150 }}>
|
||||||
|
<InputLabel>Event Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={eventType}
|
||||||
|
label="Event Type"
|
||||||
|
onChange={(e) => setEventType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
<MenuItem value="panic">Panic</MenuItem>
|
||||||
|
<MenuItem value="error">Error</MenuItem>
|
||||||
|
<MenuItem value="warning">Warning</MenuItem>
|
||||||
|
<MenuItem value="fan_change">Fan Change</MenuItem>
|
||||||
|
<MenuItem value="info">Info</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel>Server</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={serverFilter}
|
||||||
|
label="Server"
|
||||||
|
onChange={(e) => setServerFilter(e.target.value as number | '')}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All Servers</MenuItem>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<MenuItem key={server.id} value={server.id}>
|
||||||
|
{server.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell width={50}></TableCell>
|
||||||
|
<TableCell>Time</TableCell>
|
||||||
|
<TableCell>Server</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Message</TableCell>
|
||||||
|
<TableCell>Details</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedLogs?.map((log) => (
|
||||||
|
<TableRow key={log.id} hover>
|
||||||
|
<TableCell>{getEventIcon(log.event_type)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.server_id
|
||||||
|
? servers?.find((s) => s.id === log.server_id)?.name ||
|
||||||
|
`Server ${log.server_id}`
|
||||||
|
: 'System'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={log.event_type}
|
||||||
|
color={getEventColor(log.event_type) as any}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{log.message}</TableCell>
|
||||||
|
<TableCell>{log.details}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!paginatedLogs?.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center">
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4 }}>
|
||||||
|
No logs found
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||||
|
<Pagination
|
||||||
|
count={totalPages}
|
||||||
|
page={page}
|
||||||
|
onChange={(_, value) => setPage(value)}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,745 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
Slider,
|
||||||
|
Alert,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
CircularProgress,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Speed as SpeedIcon,
|
||||||
|
Thermostat as TempIcon,
|
||||||
|
Power as PowerIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { serversApi, fanControlApi, dashboardApi } from '../utils/api';
|
||||||
|
import FanCurveManager from '../components/FanCurveManager';
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel(props: TabPanelProps) {
|
||||||
|
const { children, value, index, ...other } = props;
|
||||||
|
return (
|
||||||
|
<div hidden={value !== index} {...other}>
|
||||||
|
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const serverId = parseInt(id || '0');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [fanSpeed, setFanSpeed] = useState(50);
|
||||||
|
const [selectedFan, setSelectedFan] = useState('0xff');
|
||||||
|
|
||||||
|
const { data: server, isLoading: isServerLoading } = useQuery({
|
||||||
|
queryKey: ['server', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await serversApi.getById(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
// Server config rarely changes
|
||||||
|
refetchInterval: 60000,
|
||||||
|
staleTime: 55000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sensors, refetch: refetchSensors } = useQuery({
|
||||||
|
queryKey: ['sensors', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await serversApi.getSensors(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 30000, // 30 seconds - matches sensor collector
|
||||||
|
staleTime: 25000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get SSH sensors for core temps - use dedicated endpoint
|
||||||
|
const { data: sshSensors, isLoading: isSSHSensorsLoading } = useQuery({
|
||||||
|
queryKey: ['ssh-sensors', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!server?.use_ssh) return null;
|
||||||
|
try {
|
||||||
|
const response = await serversApi.getSSHSensors(serverId);
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!server?.use_ssh,
|
||||||
|
refetchInterval: 30000, // SSH is slow - refresh less frequently
|
||||||
|
staleTime: 25000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dashboardData } = useQuery({
|
||||||
|
queryKey: ['dashboard-server', serverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await dashboardApi.getServerData(serverId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Historical data doesn't change often
|
||||||
|
staleTime: 55000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableManualMutation = useMutation({
|
||||||
|
mutationFn: () => fanControlApi.enableManual(serverId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableManualMutation = useMutation({
|
||||||
|
mutationFn: () => fanControlApi.disableManual(serverId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setFanSpeedMutation = useMutation({
|
||||||
|
mutationFn: ({ fanId, speed }: { fanId: string; speed: number }) =>
|
||||||
|
fanControlApi.setSpeed(serverId, { fan_id: fanId, speed_percent: speed }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFanSpeedChange = (_: Event, value: number | number[]) => {
|
||||||
|
setFanSpeed(value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyFanSpeed = () => {
|
||||||
|
setFanSpeedMutation.mutate({ fanId: selectedFan, speed: fanSpeed });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isServerLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return <Alert severity="error">Server not found</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || [];
|
||||||
|
|
||||||
|
// Format SSH CPU temps for display
|
||||||
|
const sshCPUTemps = sshSensors?.cpu_temps || [];
|
||||||
|
|
||||||
|
// Get detected fans from IPMI sensors, sorted by fan number
|
||||||
|
const detectedFans = sensors?.fans
|
||||||
|
? [...sensors.fans].sort((a, b) => a.fan_number - b.fan_number)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4">{server.name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
IPMI: {server.ipmi_host}:{server.ipmi_port} • {server.vendor.toUpperCase()}
|
||||||
|
{server.use_ssh && ` • SSH: ${server.ssh_host || server.ipmi_host}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{server.manual_control_enabled ? (
|
||||||
|
<Chip color="warning" label="Manual Control" icon={<SpeedIcon />} />
|
||||||
|
) : (
|
||||||
|
<Chip color="default" label="Automatic" />
|
||||||
|
)}
|
||||||
|
{server.auto_control_enabled && (
|
||||||
|
<Chip color="success" label="Auto Curve" />
|
||||||
|
)}
|
||||||
|
{server.use_ssh && (
|
||||||
|
<Chip color="info" label="SSH Enabled" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
|
||||||
|
<Tab label="Overview" />
|
||||||
|
<Tab label="Fan Control" />
|
||||||
|
<Tab label="Sensors" />
|
||||||
|
<Tab label="Power" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* CPU Temps from SSH (preferred) or IPMI */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
<TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
CPU Core Temperatures
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isSSHSensorsLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
|
Loading SSH sensors...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : sshCPUTemps.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{sshCPUTemps.map((cpu: any, idx: number) => (
|
||||||
|
<Box key={idx} sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
{cpu.cpu_name || `CPU ${idx + 1}`}
|
||||||
|
{cpu.package_temp && ` (Package: ${cpu.package_temp}°C)`}
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
|
||||||
|
<Grid item xs={6} sm={4} key={coreName}>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ p: 1.5, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="success">
|
||||||
|
{temp as number}°C
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{coreName}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : cpuTemps.length > 0 ? (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{cpuTemps.map((temp) => (
|
||||||
|
<Grid item xs={6} key={temp.name}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{temp.value.toFixed(1)}°C</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{temp.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={temp.status}
|
||||||
|
color={temp.status === 'ok' ? 'success' : 'error'}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info">
|
||||||
|
No CPU temperature data available. Enable SSH for detailed core temperatures.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Fan Speeds */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
<SpeedIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Fan Speeds
|
||||||
|
</Typography>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Fan</TableCell>
|
||||||
|
<TableCell align="right">RPM</TableCell>
|
||||||
|
<TableCell align="right">Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sensors?.fans.map((fan) => (
|
||||||
|
<TableRow key={fan.fan_id}>
|
||||||
|
<TableCell>Fan {fan.fan_number}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{fan.speed_rpm?.toLocaleString() || 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={fan.speed_rpm ? 'OK' : 'Unknown'}
|
||||||
|
color={fan.speed_rpm ? 'success' : 'default'}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Power Consumption */}
|
||||||
|
{dashboardData?.power_consumption && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Power Consumption
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Handle numeric power value (from cache) */}
|
||||||
|
{typeof dashboardData.power_consumption === 'number' && (
|
||||||
|
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center', maxWidth: 300 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Current Power Consumption
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" color="primary.main" sx={{ mt: 1 }}>
|
||||||
|
{Math.round(dashboardData.power_consumption)}W
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Handle dictionary power data (from live IPMI) */}
|
||||||
|
{typeof dashboardData.power_consumption === 'object' && (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{Object.entries(dashboardData.power_consumption)
|
||||||
|
.filter(([_, value]) => {
|
||||||
|
// Filter out empty values, timestamps, and metadata
|
||||||
|
if (!value || value === '') return false;
|
||||||
|
if (typeof value === 'string' && value.includes('UTC')) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(([key, value]) => {
|
||||||
|
// Show the raw value as-is from IPMI
|
||||||
|
const displayValue = typeof value === 'string' ? value : String(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid item xs={6} md={3} key={key}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{key}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" sx={{ mt: 0.5 }}>
|
||||||
|
{displayValue}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Fan Control Tab */}
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Manual Fan Control
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Enable manual control to set fan speeds. When disabled, the server uses automatic fan control.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={server.manual_control_enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
enableManualMutation.mutate();
|
||||||
|
} else {
|
||||||
|
disableManualMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable Manual Fan Control"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{server.manual_control_enabled && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Set Fan Speed
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{detectedFans.length === 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
No fans detected via IPMI. Please check your server connection.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography gutterBottom>
|
||||||
|
Target: {selectedFan === '0xff' ? 'All Fans' :
|
||||||
|
detectedFans.find(f => f.fan_id === selectedFan)
|
||||||
|
? `Fan ${detectedFans.find(f => f.fan_id === selectedFan)?.fan_number}`
|
||||||
|
: 'Unknown Fan'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant={selectedFan === '0xff' ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedFan('0xff')}
|
||||||
|
disabled={detectedFans.length === 0}
|
||||||
|
>
|
||||||
|
All Fans
|
||||||
|
</Button>
|
||||||
|
{detectedFans.map((fan) => (
|
||||||
|
<Button
|
||||||
|
key={fan.fan_id}
|
||||||
|
variant={selectedFan === fan.fan_id ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedFan(fan.fan_id)}
|
||||||
|
title={`Fan ${fan.fan_number} - ${fan.speed_rpm ? fan.speed_rpm + ' RPM' : 'No RPM data'}`}
|
||||||
|
>
|
||||||
|
Fan {fan.fan_number}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, py: 2 }}>
|
||||||
|
<Typography gutterBottom color={detectedFans.length === 0 ? 'text.secondary' : 'inherit'}>
|
||||||
|
Speed: {fanSpeed}%
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={fanSpeed}
|
||||||
|
onChange={handleFanSpeedChange}
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
marks={[
|
||||||
|
{ value: 10, label: '10%' },
|
||||||
|
{ value: 50, label: '50%' },
|
||||||
|
{ value: 100, label: '100%' },
|
||||||
|
]}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v) => `${v}%`}
|
||||||
|
disabled={detectedFans.length === 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
onClick={handleApplyFanSpeed}
|
||||||
|
disabled={setFanSpeedMutation.isPending || detectedFans.length === 0}
|
||||||
|
startIcon={<SpeedIcon />}
|
||||||
|
>
|
||||||
|
Apply {fanSpeed}% Speed
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Safety Settings
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={server.panic_mode_enabled} disabled />}
|
||||||
|
label="Panic Mode (Auto 100% on sensor loss)"
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||||
|
Timeout: {server.panic_timeout_seconds} seconds
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={server.third_party_pcie_response} disabled />}
|
||||||
|
label="3rd Party PCIe Card Response"
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||||
|
Controls fan response when using non-Dell PCIe cards
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Fan Curves Section */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FanCurveManager serverId={serverId} server={server} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Sensors Tab - Merged IPMI and SSH */}
|
||||||
|
<TabPanel value={tabValue} index={2}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* SSH Core Temps */}
|
||||||
|
{isSSHSensorsLoading ? (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||||||
|
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||||
|
<Typography color="text.secondary">Loading SSH sensors...</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
) : sshCPUTemps.length > 0 && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
CPU Core Temperatures (lm-sensors via SSH)
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{sshCPUTemps.map((cpu: any, idx: number) => (
|
||||||
|
<Grid item xs={12} md={6} key={idx}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
|
||||||
|
{cpu.cpu_name || `CPU ${idx + 1}`}
|
||||||
|
{cpu.package_temp && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Package: ${cpu.package_temp}°C`}
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Core</TableCell>
|
||||||
|
<TableCell align="right">Temp</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
|
||||||
|
<TableRow key={coreName}>
|
||||||
|
<TableCell>{coreName}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Chip
|
||||||
|
label={`${temp as number}°C`}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw SSH Sensors Data (if available) */}
|
||||||
|
{sshSensors?.raw_data && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
Raw lm-sensors Data
|
||||||
|
</Typography>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
<pre style={{ margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
{JSON.stringify(sshSensors.raw_data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Paper>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IPMI Temperatures */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">IPMI Temperature Sensors</Typography>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton onClick={() => refetchSensors()}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Sensor</TableCell>
|
||||||
|
<TableCell>Location</TableCell>
|
||||||
|
<TableCell align="right">Value</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sensors?.temperatures.map((temp) => (
|
||||||
|
<TableRow key={temp.name}>
|
||||||
|
<TableCell>{temp.name}</TableCell>
|
||||||
|
<TableCell>{temp.location}</TableCell>
|
||||||
|
<TableCell align="right">{temp.value.toFixed(1)}°C</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={temp.status}
|
||||||
|
color={temp.status === 'ok' ? 'success' : 'error'}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* IPMI All Sensors */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
All IPMI Sensors
|
||||||
|
</Typography>
|
||||||
|
<TableContainer sx={{ maxHeight: 400 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Sensor</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell align="right">Value</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sensors?.all_sensors.slice(0, 50).map((sensor) => (
|
||||||
|
<TableRow key={sensor.name}>
|
||||||
|
<TableCell>{sensor.name}</TableCell>
|
||||||
|
<TableCell>{sensor.sensor_type}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{sensor.value} {sensor.unit}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Power Tab */}
|
||||||
|
<TabPanel value={tabValue} index={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Power Information
|
||||||
|
</Typography>
|
||||||
|
{dashboardData?.power_consumption ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{Object.entries(dashboardData.power_consumption)
|
||||||
|
.filter(([_key, value]) => {
|
||||||
|
// Filter out entries that look like timestamps or are too messy
|
||||||
|
const valueStr = String(value);
|
||||||
|
return !valueStr.includes('UTC') &&
|
||||||
|
!valueStr.includes('Peak Time') &&
|
||||||
|
!valueStr.includes('Statistic') &&
|
||||||
|
valueStr.length < 50;
|
||||||
|
})
|
||||||
|
.map(([key, value]) => {
|
||||||
|
let displayValue = String(value);
|
||||||
|
let displayKey = key;
|
||||||
|
|
||||||
|
// Clean up Dell power monitor output
|
||||||
|
if (value.includes('Reading')) {
|
||||||
|
const match = value.match(/Reading\s*:\s*([\d.]+)\s*(\w+)/);
|
||||||
|
if (match) {
|
||||||
|
displayValue = `${match[1]} ${match[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.includes('Statistic')) {
|
||||||
|
const match = value.match(/Statistic\s*:\s*(.+)/);
|
||||||
|
if (match) {
|
||||||
|
displayValue = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid item xs={12} md={4} key={key}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
|
||||||
|
{displayKey.replace(/_/g, ' ')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 1 }}>
|
||||||
|
{displayValue}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info">
|
||||||
|
Power consumption data is not available for this server.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,622 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Tooltip,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Stepper,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Speed as SpeedIcon,
|
||||||
|
Computer as ComputerIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { serversApi } from '../utils/api';
|
||||||
|
import type { Server } from '../types';
|
||||||
|
|
||||||
|
const STEPS = ['IPMI Connection', 'SSH Connection (Optional)', 'Review'];
|
||||||
|
|
||||||
|
export default function ServerList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// Basic
|
||||||
|
name: '',
|
||||||
|
vendor: 'dell',
|
||||||
|
// IPMI
|
||||||
|
ipmi_host: '',
|
||||||
|
ipmi_port: 623,
|
||||||
|
ipmi_username: '',
|
||||||
|
ipmi_password: '',
|
||||||
|
// SSH
|
||||||
|
use_ssh: false,
|
||||||
|
ssh_host: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
ssh_username: '',
|
||||||
|
ssh_password: '',
|
||||||
|
});
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const { data: servers, isLoading } = useQuery({
|
||||||
|
queryKey: ['servers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await serversApi.getAll();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: serversApi.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers'] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setFormError(error.response?.data?.detail || 'Failed to create server');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: any }) =>
|
||||||
|
serversApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers'] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setFormError(error.response?.data?.detail || 'Failed to update server');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: serversApi.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenDialog = (server?: Server) => {
|
||||||
|
if (server) {
|
||||||
|
setEditingServer(server);
|
||||||
|
setFormData({
|
||||||
|
name: server.name,
|
||||||
|
vendor: server.vendor,
|
||||||
|
ipmi_host: server.ipmi_host,
|
||||||
|
ipmi_port: server.ipmi_port,
|
||||||
|
ipmi_username: server.ipmi_username,
|
||||||
|
ipmi_password: '',
|
||||||
|
use_ssh: server.use_ssh,
|
||||||
|
ssh_host: server.ssh_host || '',
|
||||||
|
ssh_port: server.ssh_port,
|
||||||
|
ssh_username: server.ssh_username || '',
|
||||||
|
ssh_password: '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingServer(null);
|
||||||
|
setActiveStep(0);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
vendor: 'dell',
|
||||||
|
ipmi_host: '',
|
||||||
|
ipmi_port: 623,
|
||||||
|
ipmi_username: '',
|
||||||
|
ipmi_password: '',
|
||||||
|
use_ssh: false,
|
||||||
|
ssh_host: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
ssh_username: '',
|
||||||
|
ssh_password: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setFormError('');
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingServer(null);
|
||||||
|
setActiveStep(0);
|
||||||
|
setFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateStep = (step: number): boolean => {
|
||||||
|
if (step === 0) {
|
||||||
|
// Validate IPMI fields
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
setFormError('Server name is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!formData.ipmi_host.trim()) {
|
||||||
|
setFormError('IPMI IP address/hostname is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!formData.ipmi_username.trim()) {
|
||||||
|
setFormError('IPMI username is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!editingServer && !formData.ipmi_password) {
|
||||||
|
setFormError('IPMI password is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormError('');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (validateStep(activeStep)) {
|
||||||
|
setActiveStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setFormError('');
|
||||||
|
setActiveStep((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const data = {
|
||||||
|
name: formData.name,
|
||||||
|
vendor: formData.vendor,
|
||||||
|
ipmi: {
|
||||||
|
ipmi_host: formData.ipmi_host,
|
||||||
|
ipmi_port: formData.ipmi_port,
|
||||||
|
ipmi_username: formData.ipmi_username,
|
||||||
|
ipmi_password: formData.ipmi_password,
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
use_ssh: formData.use_ssh,
|
||||||
|
ssh_host: formData.ssh_host || formData.ipmi_host,
|
||||||
|
ssh_port: formData.ssh_port,
|
||||||
|
ssh_username: formData.ssh_username || formData.ipmi_username,
|
||||||
|
ssh_password: formData.ssh_password,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingServer) {
|
||||||
|
const updateData: any = {
|
||||||
|
name: data.name,
|
||||||
|
vendor: data.vendor,
|
||||||
|
ipmi_host: data.ipmi.ipmi_host,
|
||||||
|
ipmi_port: data.ipmi.ipmi_port,
|
||||||
|
ipmi_username: data.ipmi.ipmi_username,
|
||||||
|
use_ssh: data.ssh.use_ssh,
|
||||||
|
ssh_host: data.ssh.ssh_host,
|
||||||
|
ssh_port: data.ssh.ssh_port,
|
||||||
|
ssh_username: data.ssh.ssh_username,
|
||||||
|
};
|
||||||
|
if (data.ipmi.ipmi_password) {
|
||||||
|
updateData.ipmi_password = data.ipmi.ipmi_password;
|
||||||
|
}
|
||||||
|
if (data.ssh.ssh_password) {
|
||||||
|
updateData.ssh_password = data.ssh.ssh_password;
|
||||||
|
}
|
||||||
|
updateMutation.mutate({ id: editingServer.id, data: updateData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = (step: number) => {
|
||||||
|
switch (step) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
<ComputerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
IPMI Connection (Required)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Enter the IPMI/iDRAC/iLO credentials for fan control and basic sensor reading.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Server Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
helperText="A friendly name for this server"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Server Vendor</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.vendor}
|
||||||
|
label="Server Vendor"
|
||||||
|
onChange={(e) => setFormData({ ...formData, vendor: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="dell">Dell (iDRAC)</MenuItem>
|
||||||
|
<MenuItem value="hpe">HPE (iLO)</MenuItem>
|
||||||
|
<MenuItem value="supermicro">Supermicro</MenuItem>
|
||||||
|
<MenuItem value="other">Other</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="IPMI IP Address / Hostname"
|
||||||
|
value={formData.ipmi_host}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
helperText="The IP address of your iDRAC/iLO/IPMI interface"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="IPMI Port"
|
||||||
|
value={formData.ipmi_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ipmi_port: parseInt(e.target.value) || 623 })}
|
||||||
|
margin="normal"
|
||||||
|
helperText="Default is 623 for most servers"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="IPMI Username"
|
||||||
|
value={formData.ipmi_username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
label={editingServer ? 'IPMI Password (leave blank to keep current)' : 'IPMI Password'}
|
||||||
|
value={formData.ipmi_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required={!editingServer}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
SSH Connection (Optional)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
SSH provides more detailed sensor data via lm-sensors (CPU core temperatures, etc.).
|
||||||
|
If not configured, only IPMI sensors will be used.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={formData.use_ssh}
|
||||||
|
onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable SSH for detailed sensor data"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formData.use_ssh && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="SSH Host"
|
||||||
|
value={formData.ssh_host}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
placeholder={formData.ipmi_host}
|
||||||
|
helperText="Leave empty to use IPMI host"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="SSH Port"
|
||||||
|
value={formData.ssh_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="SSH Username"
|
||||||
|
value={formData.ssh_username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
placeholder={formData.ipmi_username}
|
||||||
|
helperText="Leave empty to use IPMI username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
label={editingServer ? 'SSH Password (leave blank to keep current)' : 'SSH Password'}
|
||||||
|
value={formData.ssh_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
helperText="Password for SSH authentication"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Review Configuration
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
|
Basic Info
|
||||||
|
</Typography>
|
||||||
|
<Typography><strong>Name:</strong> {formData.name}</Typography>
|
||||||
|
<Typography><strong>Vendor:</strong> {formData.vendor.toUpperCase()}</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
|
IPMI Connection
|
||||||
|
</Typography>
|
||||||
|
<Typography><strong>Host:</strong> {formData.ipmi_host}:{formData.ipmi_port}</Typography>
|
||||||
|
<Typography><strong>Username:</strong> {formData.ipmi_username}</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
|
SSH Connection
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
<strong>Status:</strong> {formData.use_ssh ? 'Enabled' : 'Disabled'}
|
||||||
|
</Typography>
|
||||||
|
{formData.use_ssh && (
|
||||||
|
<>
|
||||||
|
<Typography><strong>Host:</strong> {formData.ssh_host || formData.ipmi_host}:{formData.ssh_port}</Typography>
|
||||||
|
<Typography><strong>Username:</strong> {formData.ssh_username || formData.ipmi_username}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = (server: Server) => {
|
||||||
|
if (server.auto_control_enabled) {
|
||||||
|
return <Chip size="small" color="success" label="Auto" icon={<SpeedIcon />} />;
|
||||||
|
}
|
||||||
|
if (server.manual_control_enabled) {
|
||||||
|
return <Chip size="small" color="warning" label="Manual" />;
|
||||||
|
}
|
||||||
|
return <Chip size="small" color="default" label="Automatic" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Typography variant="h4">Servers</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
>
|
||||||
|
Add Server
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>IPMI Host</TableCell>
|
||||||
|
<TableCell>SSH</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Last Seen</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<TableRow
|
||||||
|
key={server.id}
|
||||||
|
hover
|
||||||
|
onClick={() => navigate(`/servers/${server.id}`)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{server.name}
|
||||||
|
<Chip size="small" label={server.vendor.toUpperCase()} variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{server.ipmi_host}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{server.use_ssh ? (
|
||||||
|
<Chip size="small" color="success" label="Enabled" />
|
||||||
|
) : (
|
||||||
|
<Chip size="small" color="default" label="Disabled" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusChip(server)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{server.last_seen
|
||||||
|
? new Date(server.last_seen).toLocaleString()
|
||||||
|
: 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Tooltip title="Fan Curves">
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/servers/${server.id}/curves`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SpeedIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenDialog(server);
|
||||||
|
}}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Are you sure you want to delete this server?')) {
|
||||||
|
deleteMutation.mutate(server.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!servers?.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center">
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4 }}>
|
||||||
|
No servers added yet. Click "Add Server" to get started.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* Add/Edit Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={openDialog}
|
||||||
|
onClose={handleCloseDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={false}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingServer ? 'Edit Server' : 'Add Server'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{formError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{formError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editingServer && (
|
||||||
|
<Stepper activeStep={activeStep} sx={{ mb: 2 }}>
|
||||||
|
{STEPS.map((label) => (
|
||||||
|
<Step key={label}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingServer ? (
|
||||||
|
// Editing mode - show all fields at once
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>Basic Info</Typography>
|
||||||
|
<TextField fullWidth label="Name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} margin="normal" />
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>IPMI</Typography>
|
||||||
|
<TextField fullWidth label="IPMI Host" value={formData.ipmi_host} onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })} margin="normal" />
|
||||||
|
<TextField fullWidth label="IPMI Username" value={formData.ipmi_username} onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })} margin="normal" />
|
||||||
|
<TextField fullWidth type="password" label="IPMI Password (leave blank to keep)" value={formData.ipmi_password} onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })} margin="normal" />
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>SSH</Typography>
|
||||||
|
<FormControlLabel control={<Switch checked={formData.use_ssh} onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })} />} label="Enable SSH" />
|
||||||
|
{formData.use_ssh && (
|
||||||
|
<>
|
||||||
|
<TextField fullWidth label="SSH Host" value={formData.ssh_host} onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })} margin="normal" />
|
||||||
|
<TextField fullWidth label="SSH Username" value={formData.ssh_username} onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })} margin="normal" />
|
||||||
|
<TextField fullWidth type="password" label="SSH Password (leave blank to keep)" value={formData.ssh_password} onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })} margin="normal" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
// Creating mode - show stepper
|
||||||
|
renderStepContent(activeStep)
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||||
|
|
||||||
|
{editingServer ? (
|
||||||
|
<Button onClick={handleSubmit} variant="contained" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? <CircularProgress size={24} /> : 'Update'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleBack} disabled={activeStep === 0}>Back</Button>
|
||||||
|
{activeStep === STEPS.length - 1 ? (
|
||||||
|
<Button onClick={handleSubmit} variant="contained" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? <CircularProgress size={24} /> : 'Create'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleNext} variant="contained">Next</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Stepper,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { setupApi } from '../utils/api';
|
||||||
|
|
||||||
|
const steps = ['Welcome', 'Create Admin Account'];
|
||||||
|
|
||||||
|
export default function SetupWizard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const setupMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
setupApi.completeSetup(formData.username, formData.password, formData.confirmPassword),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||||
|
navigate('/login');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.username || formData.username.length < 3) {
|
||||||
|
errors.username = 'Username must be at least 3 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password || formData.password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
setupMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Welcome to IPMI Fan Control
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" paragraph>
|
||||||
|
This wizard will guide you through the initial setup of your IPMI Fan Control
|
||||||
|
application. You'll need to create an administrator account to get started.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" paragraph>
|
||||||
|
Features:
|
||||||
|
</Typography>
|
||||||
|
<ul>
|
||||||
|
<li>Control fan speeds on Dell T710 and compatible servers</li>
|
||||||
|
<li>Set custom fan curves based on temperature sensors</li>
|
||||||
|
<li>Automatic panic mode for safety</li>
|
||||||
|
<li>Monitor server health and power consumption</li>
|
||||||
|
<li>Support for multiple servers</li>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setActiveStep(1)}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Create Administrator Account
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
This account will have full access to manage servers and fan control settings.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{setupMutation.error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{(setupMutation.error as any)?.response?.data?.detail || 'Setup failed'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, username: e.target.value })
|
||||||
|
}
|
||||||
|
error={!!formErrors.username}
|
||||||
|
helperText={formErrors.username}
|
||||||
|
margin="normal"
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
}
|
||||||
|
error={!!formErrors.password}
|
||||||
|
helperText={formErrors.password}
|
||||||
|
margin="normal"
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
label="Confirm Password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, confirmPassword: e.target.value })
|
||||||
|
}
|
||||||
|
error={!!formErrors.confirmPassword}
|
||||||
|
helperText={formErrors.confirmPassword}
|
||||||
|
margin="normal"
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setActiveStep(0)}
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
startIcon={setupMutation.isPending ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
Complete Setup
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ maxWidth: 600, width: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||||
|
{steps.map((label) => (
|
||||||
|
<Step key={label}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
{renderStepContent()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User } from '../types';
|
||||||
|
import { authApi } from '../utils/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
fetchUser: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(username, password);
|
||||||
|
const { access_token } = response.data;
|
||||||
|
localStorage.setItem('token', access_token);
|
||||||
|
set({ token: access_token, isAuthenticated: true });
|
||||||
|
await get().fetchUser();
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.response?.data?.detail || 'Login failed',
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUser: async () => {
|
||||||
|
try {
|
||||||
|
const response = await authApi.getMe();
|
||||||
|
set({ user: response.data, isAuthenticated: true });
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
set({ user: null, token: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ token: state.token }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_login: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPMI Settings
|
||||||
|
export interface IPMISettings {
|
||||||
|
ipmi_host: string;
|
||||||
|
ipmi_port: number;
|
||||||
|
ipmi_username: string;
|
||||||
|
ipmi_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH Settings
|
||||||
|
export interface SSHSettings {
|
||||||
|
use_ssh: boolean;
|
||||||
|
ssh_host?: string;
|
||||||
|
ssh_port: number;
|
||||||
|
ssh_username?: string;
|
||||||
|
ssh_password?: string;
|
||||||
|
ssh_key_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
// IPMI
|
||||||
|
ipmi_host: string;
|
||||||
|
ipmi_port: number;
|
||||||
|
ipmi_username: string;
|
||||||
|
// SSH
|
||||||
|
ssh_host?: string;
|
||||||
|
ssh_port: number;
|
||||||
|
ssh_username?: string;
|
||||||
|
use_ssh: boolean;
|
||||||
|
// Other
|
||||||
|
vendor: string;
|
||||||
|
manual_control_enabled: boolean;
|
||||||
|
third_party_pcie_response: boolean;
|
||||||
|
auto_control_enabled: boolean;
|
||||||
|
panic_mode_enabled: boolean;
|
||||||
|
panic_timeout_seconds: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_seen: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanCurvePoint {
|
||||||
|
temp: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanCurve {
|
||||||
|
id: number;
|
||||||
|
server_id: number;
|
||||||
|
name: string;
|
||||||
|
curve_data: FanCurvePoint[];
|
||||||
|
sensor_source: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemperatureReading {
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
value: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanReading {
|
||||||
|
fan_id: string;
|
||||||
|
fan_number: number;
|
||||||
|
speed_rpm: number | null;
|
||||||
|
speed_percent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorReading {
|
||||||
|
name: string;
|
||||||
|
sensor_type: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemLog {
|
||||||
|
id: number;
|
||||||
|
server_id: number | null;
|
||||||
|
event_type: string;
|
||||||
|
message: string;
|
||||||
|
details: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_servers: number;
|
||||||
|
active_servers: number;
|
||||||
|
manual_control_servers: number;
|
||||||
|
auto_control_servers: number;
|
||||||
|
panic_mode_servers: number;
|
||||||
|
recent_logs: SystemLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStatus {
|
||||||
|
server: Server;
|
||||||
|
is_connected: boolean;
|
||||||
|
controller_status: {
|
||||||
|
is_running: boolean;
|
||||||
|
last_sensor_data: string | null;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSensors {
|
||||||
|
server_id: number;
|
||||||
|
temperatures: TemperatureReading[];
|
||||||
|
fans: FanReading[];
|
||||||
|
all_sensors: SensorReading[];
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanControlCommand {
|
||||||
|
fan_id: string;
|
||||||
|
speed_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoControlSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
curve_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH Sensor Data
|
||||||
|
export interface SSHSensorData {
|
||||||
|
cpu_temps: {
|
||||||
|
cpu_name: string;
|
||||||
|
core_temps: Record<string, number>;
|
||||||
|
package_temp: number | null;
|
||||||
|
}[];
|
||||||
|
raw_data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemInfo {
|
||||||
|
cpu?: string;
|
||||||
|
memory?: string;
|
||||||
|
os?: string;
|
||||||
|
uptime?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
Server,
|
||||||
|
FanCurve,
|
||||||
|
FanCurvePoint,
|
||||||
|
TemperatureReading,
|
||||||
|
FanReading,
|
||||||
|
SensorReading,
|
||||||
|
SystemLog,
|
||||||
|
DashboardStats,
|
||||||
|
ServerStatus,
|
||||||
|
ServerSensors,
|
||||||
|
FanControlCommand,
|
||||||
|
AutoControlSettings,
|
||||||
|
SSHSensorData,
|
||||||
|
SystemInfo,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: `${API_URL}/api`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor to handle auth errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
api.post<{ access_token: string; token_type: string }>('/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
getMe: () => api.get<User>('/auth/me'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup API
|
||||||
|
export const setupApi = {
|
||||||
|
getStatus: () => api.get<{ setup_complete: boolean }>('/setup/status'),
|
||||||
|
completeSetup: (username: string, password: string, confirmPassword: string) =>
|
||||||
|
api.post<User>('/setup/complete', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
confirm_password: confirmPassword,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Servers API
|
||||||
|
export const serversApi = {
|
||||||
|
getAll: () => api.get<Server[]>('/servers'),
|
||||||
|
getById: (id: number) => api.get<Server>(`/servers/${id}`),
|
||||||
|
create: (data: {
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
ipmi: {
|
||||||
|
ipmi_host: string;
|
||||||
|
ipmi_port: number;
|
||||||
|
ipmi_username: string;
|
||||||
|
ipmi_password: string;
|
||||||
|
};
|
||||||
|
ssh: {
|
||||||
|
use_ssh: boolean;
|
||||||
|
ssh_host?: string;
|
||||||
|
ssh_port: number;
|
||||||
|
ssh_username?: string;
|
||||||
|
ssh_password?: string;
|
||||||
|
ssh_key_file?: string;
|
||||||
|
};
|
||||||
|
}) => api.post<Server>('/servers', data),
|
||||||
|
update: (id: number, data: any) =>
|
||||||
|
api.put<Server>(`/servers/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/servers/${id}`),
|
||||||
|
getStatus: (id: number) => api.get<ServerStatus>(`/servers/${id}/status`),
|
||||||
|
getSensors: (id: number) => api.get<ServerSensors>(`/servers/${id}/sensors`),
|
||||||
|
getPower: (id: number) => api.get<Record<string, string>>(`/servers/${id}/power`),
|
||||||
|
// SSH endpoints
|
||||||
|
getSSHSensors: (id: number) => api.get<SSHSensorData>(`/servers/${id}/ssh/sensors`),
|
||||||
|
getSSHSystemInfo: (id: number) => api.get<SystemInfo>(`/servers/${id}/ssh/system-info`),
|
||||||
|
executeSSHCommand: (id: number, command: string) =>
|
||||||
|
api.post<{ exit_code: number; stdout: string; stderr: string }>(`/servers/${id}/ssh/execute`, { command }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fan Control API
|
||||||
|
export const fanControlApi = {
|
||||||
|
enableManual: (serverId: number) =>
|
||||||
|
api.post(`/servers/${serverId}/fans/manual/enable`),
|
||||||
|
disableManual: (serverId: number) =>
|
||||||
|
api.post(`/servers/${serverId}/fans/manual/disable`),
|
||||||
|
setSpeed: (serverId: number, command: FanControlCommand) =>
|
||||||
|
api.post(`/servers/${serverId}/fans/set`, command),
|
||||||
|
enableAuto: (serverId: number, settings: AutoControlSettings) =>
|
||||||
|
api.post(`/servers/${serverId}/auto-control/enable`, settings),
|
||||||
|
disableAuto: (serverId: number) =>
|
||||||
|
api.post(`/servers/${serverId}/auto-control/disable`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fan Curves API
|
||||||
|
export const fanCurvesApi = {
|
||||||
|
getAll: (serverId: number) =>
|
||||||
|
api.get<FanCurve[]>(`/servers/${serverId}/fan-curves`),
|
||||||
|
create: (
|
||||||
|
serverId: number,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
curve_data: FanCurvePoint[];
|
||||||
|
sensor_source: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
) => api.post<FanCurve>(`/servers/${serverId}/fan-curves`, data),
|
||||||
|
update: (
|
||||||
|
serverId: number,
|
||||||
|
curveId: number,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
curve_data: FanCurvePoint[];
|
||||||
|
sensor_source: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
) => api.put<FanCurve>(`/servers/${serverId}/fan-curves/${curveId}`, data),
|
||||||
|
delete: (serverId: number, curveId: number) =>
|
||||||
|
api.delete(`/servers/${serverId}/fan-curves/${curveId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard API
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: () => api.get<DashboardStats>('/dashboard/stats'),
|
||||||
|
getServersOverview: () =>
|
||||||
|
api.get<{ servers: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
is_active: boolean;
|
||||||
|
manual_control_enabled: boolean;
|
||||||
|
auto_control_enabled: boolean;
|
||||||
|
max_temp: number | null;
|
||||||
|
avg_fan_speed: number | null;
|
||||||
|
power_consumption: number | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
cached: boolean;
|
||||||
|
}> }>('/dashboard/servers-overview'),
|
||||||
|
refreshServer: (serverId: number) =>
|
||||||
|
api.post<{ success: boolean; message: string }>(`/dashboard/refresh-server/${serverId}`),
|
||||||
|
getServerData: (serverId: number) =>
|
||||||
|
api.get<{
|
||||||
|
server: Server;
|
||||||
|
current_temperatures: TemperatureReading[];
|
||||||
|
current_fans: FanReading[];
|
||||||
|
recent_sensor_data: SensorReading[];
|
||||||
|
recent_fan_data: FanReading[];
|
||||||
|
power_consumption: Record<string, string> | null;
|
||||||
|
}>(`/dashboard/servers/${serverId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logs API
|
||||||
|
export const logsApi = {
|
||||||
|
getAll: (params?: { server_id?: number; event_type?: string; limit?: number }) =>
|
||||||
|
api.get<SystemLog[]>('/logs', { params }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
215
install.sh
|
|
@ -1,215 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# IPMI Controller - Install Script with Persistence
|
|
||||||
# This sets up auto-start and ensures settings persist
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
INSTALL_DIR="${1:-/opt/ipmi-controller}"
|
|
||||||
DATA_DIR="$INSTALL_DIR/data"
|
|
||||||
SERVICE_NAME="ipmi-controller"
|
|
||||||
USER="${SUDO_USER:-$USER}"
|
|
||||||
|
|
||||||
echo "🌡️ IPMI Controller Installation"
|
|
||||||
echo "================================"
|
|
||||||
echo "Install dir: $INSTALL_DIR"
|
|
||||||
echo "Data dir: $DATA_DIR"
|
|
||||||
echo "Service user: $USER"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if running as root for system-wide install
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
|
||||||
echo "⚠️ Not running as root. Installing to $HOME/ipmi-controller instead."
|
|
||||||
INSTALL_DIR="$HOME/ipmi-controller"
|
|
||||||
DATA_DIR="$INSTALL_DIR/data"
|
|
||||||
SYSTEM_INSTALL=false
|
|
||||||
else
|
|
||||||
SYSTEM_INSTALL=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create directories
|
|
||||||
echo "📁 Creating directories..."
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
mkdir -p "$DATA_DIR"
|
|
||||||
|
|
||||||
# Copy files
|
|
||||||
echo "📋 Copying files..."
|
|
||||||
cp -r . "$INSTALL_DIR/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Ensure data directory exists with proper files
|
|
||||||
if [ ! -f "$DATA_DIR/config.json" ]; then
|
|
||||||
echo "⚙️ Creating default config..."
|
|
||||||
cat > "$DATA_DIR/config.json" << 'EOF'
|
|
||||||
{
|
|
||||||
"ipmi_host": "",
|
|
||||||
"ipmi_username": "",
|
|
||||||
"ipmi_password": "",
|
|
||||||
"ipmi_port": 623,
|
|
||||||
"http_sensor_enabled": false,
|
|
||||||
"http_sensor_url": "",
|
|
||||||
"http_sensor_timeout": 10,
|
|
||||||
"enabled": false,
|
|
||||||
"poll_interval": 10,
|
|
||||||
"min_speed": 10,
|
|
||||||
"max_speed": 100,
|
|
||||||
"panic_temp": 85,
|
|
||||||
"panic_speed": 100,
|
|
||||||
"panic_on_no_data": true,
|
|
||||||
"no_data_timeout": 60,
|
|
||||||
"primary_sensor": "cpu",
|
|
||||||
"sensor_preference": "auto",
|
|
||||||
"fans": {},
|
|
||||||
"fan_groups": {},
|
|
||||||
"fan_curves": {
|
|
||||||
"Balanced": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 10},
|
|
||||||
{"temp": 35, "speed": 12},
|
|
||||||
{"temp": 40, "speed": 15},
|
|
||||||
{"temp": 45, "speed": 20},
|
|
||||||
{"temp": 50, "speed": 30},
|
|
||||||
{"temp": 55, "speed": 40},
|
|
||||||
{"temp": 60, "speed": 55},
|
|
||||||
{"temp": 65, "speed": 70},
|
|
||||||
{"temp": 70, "speed": 85},
|
|
||||||
{"temp": 75, "speed": 95},
|
|
||||||
{"temp": 80, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Silent": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 5},
|
|
||||||
{"temp": 40, "speed": 10},
|
|
||||||
{"temp": 50, "speed": 15},
|
|
||||||
{"temp": 55, "speed": 25},
|
|
||||||
{"temp": 60, "speed": 35},
|
|
||||||
{"temp": 65, "speed": 50},
|
|
||||||
{"temp": 70, "speed": 70},
|
|
||||||
{"temp": 75, "speed": 85},
|
|
||||||
{"temp": 80, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
},
|
|
||||||
"Performance": {
|
|
||||||
"points": [
|
|
||||||
{"temp": 30, "speed": 20},
|
|
||||||
{"temp": 35, "speed": 25},
|
|
||||||
{"temp": 40, "speed": 35},
|
|
||||||
{"temp": 45, "speed": 45},
|
|
||||||
{"temp": 50, "speed": 55},
|
|
||||||
{"temp": 55, "speed": 70},
|
|
||||||
{"temp": 60, "speed": 85},
|
|
||||||
{"temp": 65, "speed": 95},
|
|
||||||
{"temp": 70, "speed": 100}
|
|
||||||
],
|
|
||||||
"sensor_source": "cpu",
|
|
||||||
"applies_to": "all"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active_curve": "Balanced",
|
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/users.json" ]; then
|
|
||||||
echo "👤 Creating users file..."
|
|
||||||
echo '{"users": {}}' > "$DATA_DIR/users.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
echo "🐍 Installing dependencies..."
|
|
||||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
|
||||||
pip3 install -q -r "$INSTALL_DIR/requirements.txt" || pip install -q -r "$INSTALL_DIR/requirements.txt"
|
|
||||||
else
|
|
||||||
pip3 install --user -q -r "$INSTALL_DIR/requirements.txt" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install ipmitool if not present
|
|
||||||
if ! command -v ipmitool &> /dev/null; then
|
|
||||||
echo "📦 Installing ipmitool..."
|
|
||||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
|
||||||
apt-get update -qq && apt-get install -y -qq ipmitool
|
|
||||||
else
|
|
||||||
echo "⚠️ Please install ipmitool manually: sudo apt-get install ipmitool"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "✓ ipmitool already installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create systemd service (system-wide only)
|
|
||||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
|
||||||
echo "🔧 Creating systemd service..."
|
|
||||||
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
|
|
||||||
[Unit]
|
|
||||||
Description=IPMI Controller
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$USER
|
|
||||||
WorkingDirectory=$INSTALL_DIR
|
|
||||||
Environment="PYTHONUNBUFFERED=1"
|
|
||||||
Environment="DATA_DIR=$DATA_DIR"
|
|
||||||
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
|
|
||||||
ExecStop=/bin/kill -TERM \$MAINPID
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable "$SERVICE_NAME"
|
|
||||||
|
|
||||||
# Set proper ownership
|
|
||||||
chown -R "$USER:$USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Installation complete!"
|
|
||||||
echo ""
|
|
||||||
echo "Start the service:"
|
|
||||||
echo " sudo systemctl start $SERVICE_NAME"
|
|
||||||
echo ""
|
|
||||||
echo "Check status:"
|
|
||||||
echo " sudo systemctl status $SERVICE_NAME"
|
|
||||||
echo ""
|
|
||||||
echo "View logs:"
|
|
||||||
echo " sudo journalctl -u $SERVICE_NAME -f"
|
|
||||||
echo ""
|
|
||||||
echo "Access: http://$(hostname -I | awk '{print $1}'):8000"
|
|
||||||
|
|
||||||
else
|
|
||||||
# User install - create a simple start script
|
|
||||||
cat > "$INSTALL_DIR/start.sh" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
export DATA_DIR="./data"
|
|
||||||
export PYTHONUNBUFFERED=1
|
|
||||||
echo "Starting IPMI Controller..."
|
|
||||||
echo "Data directory: $DATA_DIR"
|
|
||||||
python3 web_server.py
|
|
||||||
EOF
|
|
||||||
chmod +x "$INSTALL_DIR/start.sh"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ User installation complete!"
|
|
||||||
echo ""
|
|
||||||
echo "Start manually:"
|
|
||||||
echo " cd $INSTALL_DIR && ./start.sh"
|
|
||||||
echo ""
|
|
||||||
echo "Or create a systemd service manually:"
|
|
||||||
echo " nano ~/.config/systemd/user/ipmi-controller.service"
|
|
||||||
echo ""
|
|
||||||
echo "Access: http://localhost:8000"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "📁 Your settings are stored in: $DATA_DIR"
|
|
||||||
echo " - config.json: All configuration"
|
|
||||||
echo " - users.json: User accounts"
|
|
||||||
echo ""
|
|
||||||
echo "💾 These files persist across restarts and updates!"
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=IPMI Controller - Advanced Fan Control
|
|
||||||
After=network.target
|
|
||||||
Requires=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/ipmi-controller
|
|
||||||
ExecStartPre=/bin/sh -c 'command -v ipmitool >/dev/null 2>&1 || { echo "ipmitool is required but not installed"; exit 1; }'
|
|
||||||
ExecStart=/usr/bin/python3 /opt/ipmi-controller/web_server.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
fastapi>=0.100.0
|
|
||||||
uvicorn[standard]>=0.23.0
|
|
||||||
pydantic>=2.0.0
|
|
||||||
requests>=2.31.0
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Reset password for fan controller"""
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
USERS_FILE = "/home/devmatrix/projects/fan-controller-v2/data/users.json"
|
|
||||||
|
|
||||||
def hash_password(password):
|
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
|
||||||
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print("Usage: reset_password.py <username> <new_password>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
username = sys.argv[1]
|
|
||||||
password = sys.argv[2]
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(USERS_FILE) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
data["users"][username] = hash_password(password)
|
|
||||||
|
|
||||||
with open(USERS_FILE, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
print(f"Password reset for user: {username}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple HTTP server for lm-sensors data
|
|
||||||
Run this on your Proxmox host or server with lm-sensors installed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
class SensorHandler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == '/sensors':
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['sensors', '-j'],
|
|
||||||
capture_output=True, text=True)
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(data).encode())
|
|
||||||
except Exception as e:
|
|
||||||
self.send_response(500)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({'error': str(e)}).encode())
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass # Suppress logs
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
|
|
||||||
server = HTTPServer(('0.0.0.0', port), SensorHandler)
|
|
||||||
print(f"Sensor server running on http://0.0.0.0:{port}/sensors")
|
|
||||||
print("Press Ctrl+C to stop")
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down...")
|
|
||||||
server.shutdown()
|
|
||||||
785
server.log
|
|
@ -1,785 +0,0 @@
|
||||||
INFO: Started server process [105401]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
2026-02-20 22:36:05,954 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json
|
|
||||||
2026-02-20 22:36:05,955 - __main__ - INFO - Auto-starting fan control (enabled in config)
|
|
||||||
2026-02-20 22:36:06,109 - fan_controller - INFO - Connected to IPMI at 192.168.5.191
|
|
||||||
2026-02-20 22:36:06,110 - fan_controller - INFO - HTTP sensor client initialized for http://192.168.5.200:8888
|
|
||||||
2026-02-20 22:36:06,111 - fan_controller - INFO - IPMI Controller service started
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
|
||||||
2026-02-20 22:36:06,301 - fan_controller - INFO - Manual fan control enabled
|
|
||||||
2026-02-20 22:36:11,732 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:36:11,732 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
|
|
||||||
INFO: 192.168.5.30:57657 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
return HTMLResponse(content=get_html(theme))
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<style>body{padding-bottom:80px !important;}</style>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:65161 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:65161 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
return HTMLResponse(content=get_html(theme))
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<style>body{padding-bottom:80px !important;}</style>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:51526 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:53588 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
return HTMLResponse(content=get_html(theme))
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<style>body{padding-bottom:80px !important;}</style>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:58112 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:61736 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
if not user_manager.is_setup_complete():
|
|
||||||
^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:64381 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:64381 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
if not user_manager.is_setup_complete():
|
|
||||||
^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:59631 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
if not user_manager.is_setup_complete():
|
|
||||||
^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:56967 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:56967 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
if not user_manager.is_setup_complete():
|
|
||||||
^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:51136 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:51136 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
if not user_manager.is_setup_complete():
|
|
||||||
^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
2026-02-20 22:45:21,250 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:45:21,250 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 22:45:36,965 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:45:36,965 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
2026-02-20 22:46:56,801 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:46:56,801 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 22:47:12,899 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:47:12,900 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
|
|
||||||
2026-02-20 22:48:44,782 - fan_controller - INFO - Fan 0xff speed set to 16%
|
|
||||||
2026-02-20 22:48:44,783 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
|
|
||||||
2026-02-20 22:49:00,730 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:49:00,730 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 22:49:16,428 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:49:16,428 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
2026-02-20 22:52:28,497 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:52:28,497 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 22:52:44,134 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:52:44,134 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
INFO: 192.168.5.30:57785 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
</form>
|
|
||||||
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
const data = {{
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
INFO: 192.168.5.30:50967 - "GET /favicon.ico HTTP/1.1" 200 OK
|
|
||||||
INFO: 192.168.5.30:52925 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
</form>
|
|
||||||
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
const data = {{
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
2026-02-20 22:55:50,605 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:55:50,606 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 22:56:06,087 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:56:06,087 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
2026-02-20 22:58:24,641 - fan_controller - INFO - Fan 0xff speed set to 16%
|
|
||||||
2026-02-20 22:58:24,642 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
|
|
||||||
INFO: 192.168.5.30:56982 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
</form>
|
|
||||||
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
const data = {{
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
2026-02-20 22:58:40,511 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 22:58:40,511 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
|
|
||||||
2026-02-20 22:59:56,756 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 22:59:56,756 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 23:00:13,621 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 23:00:13,622 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
2026-02-20 23:00:28,792 - fan_controller - INFO - Fan 0xff speed set to 16%
|
|
||||||
2026-02-20 23:00:28,792 - fan_controller - INFO - All fans set to 16% (Temp 41.0°C)
|
|
||||||
2026-02-20 23:00:45,314 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 23:00:45,315 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
2026-02-20 23:01:17,971 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 23:01:17,971 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 23:01:33,427 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 23:01:33,428 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
INFO: 192.168.5.30:53747 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
</form>
|
|
||||||
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
const data = {{
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
2026-02-20 23:03:08,820 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 23:03:08,820 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 23:03:26,001 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 23:03:26,001 - fan_controller - INFO - All fans set to 14% (Temp 38.0°C)
|
|
||||||
2026-02-20 23:03:58,639 - fan_controller - INFO - Fan 0xff speed set to 15%
|
|
||||||
2026-02-20 23:03:58,639 - fan_controller - INFO - All fans set to 15% (Temp 40.0°C)
|
|
||||||
2026-02-20 23:04:14,022 - fan_controller - INFO - Fan 0xff speed set to 14%
|
|
||||||
2026-02-20 23:04:14,022 - fan_controller - INFO - All fans set to 14% (Temp 39.0°C)
|
|
||||||
INFO: 192.168.5.30:49653 - "GET / HTTP/1.1" 500 Internal Server Error
|
|
||||||
ERROR: Exception in ASGI application
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
|
||||||
result = await app( # type: ignore[func-returns-value]
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
|
|
||||||
await super().__call__(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
|
||||||
await self.app(scope, receive, _send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 83, in __call__
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
|
||||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
|
|
||||||
await self.middleware_stack(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
|
|
||||||
await route.handle(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
|
|
||||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
|
||||||
raise exc
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
|
||||||
await app(scope, receive, sender)
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
|
|
||||||
response = await func(request)
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
|
|
||||||
raise e
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
|
|
||||||
raw_response = await run_endpoint_function(
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
|
||||||
return await dependant.call(**values)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1592, in root
|
|
||||||
</form>
|
|
||||||
|
|
||||||
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1424, in get_html
|
|
||||||
const data = {{
|
|
||||||
^^^^^^^
|
|
||||||
NameError: name 'padding' is not defined
|
|
||||||
2026-02-20 23:06:01,019 - fan_controller - WARNING - IPMI command failed:
|
|
||||||
INFO: Shutting down
|
|
||||||
INFO: Waiting for application shutdown.
|
|
||||||
INFO: Application shutdown complete.
|
|
||||||
INFO: Finished server process [105401]
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# IPMI Controller - lm-sensors HTTP Server Setup
|
|
||||||
# Run this on your Proxmox/Dell server to expose sensors over HTTP
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🌡️ IPMI Controller - lm-sensors HTTP Setup"
|
|
||||||
echo "============================================"
|
|
||||||
|
|
||||||
# Check if running as root
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
|
||||||
echo "❌ Please run as root (sudo)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install lm-sensors if not present
|
|
||||||
echo ""
|
|
||||||
echo "📦 Checking lm-sensors..."
|
|
||||||
if ! command -v sensors &> /dev/null; then
|
|
||||||
echo "Installing lm-sensors..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y lm-sensors
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🔧 Running sensors-detect..."
|
|
||||||
echo "Answer YES to all questions or use default values"
|
|
||||||
sensors-detect --auto
|
|
||||||
else
|
|
||||||
echo "✓ lm-sensors already installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install netcat if not present
|
|
||||||
if ! command -v nc &> /dev/null; then
|
|
||||||
echo "Installing netcat..."
|
|
||||||
apt-get install -y netcat-openbsd
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the HTTP sensors server script
|
|
||||||
SERVER_SCRIPT="/usr/local/bin/sensors-http-server.sh"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "📝 Creating HTTP server script..."
|
|
||||||
cat > "$SERVER_SCRIPT" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
# lm-sensors HTTP Server for IPMI Controller
|
|
||||||
# Serves sensor data on port 8888
|
|
||||||
|
|
||||||
PORT=${1:-8888}
|
|
||||||
|
|
||||||
echo "Starting lm-sensors HTTP server on port $PORT..."
|
|
||||||
echo "Access via: http://$(hostname -I | awk '{print $1}'):$PORT"
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
{
|
|
||||||
echo -e "HTTP/1.1 200 OK\r"
|
|
||||||
echo -e "Content-Type: text/plain\r"
|
|
||||||
echo -e "Access-Control-Allow-Origin: *\r"
|
|
||||||
echo -e "\r"
|
|
||||||
sensors -u 2>/dev/null || echo "Error reading sensors"
|
|
||||||
} | nc -l -p "$PORT" -q 1
|
|
||||||
done
|
|
||||||
EOF
|
|
||||||
|
|
||||||
chmod +x "$SERVER_SCRIPT"
|
|
||||||
echo "✓ Created $SERVER_SCRIPT"
|
|
||||||
|
|
||||||
# Create systemd service
|
|
||||||
SERVICE_FILE="/etc/systemd/system/sensors-http.service"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🔧 Creating systemd service..."
|
|
||||||
cat > "$SERVICE_FILE" << EOF
|
|
||||||
[Unit]
|
|
||||||
Description=lm-sensors HTTP Server for IPMI Controller
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=$SERVER_SCRIPT 8888
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
User=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✓ Created $SERVICE_FILE"
|
|
||||||
|
|
||||||
# Reload systemd and enable service
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Enabling and starting service..."
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable sensors-http.service
|
|
||||||
systemctl start sensors-http.service
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
sleep 2
|
|
||||||
if systemctl is-active --quiet sensors-http.service; then
|
|
||||||
echo "✓ Service is running!"
|
|
||||||
echo ""
|
|
||||||
echo "🌐 HTTP Endpoint: http://$(hostname -I | awk '{print $1}'):8888"
|
|
||||||
echo ""
|
|
||||||
echo "Test with: curl http://$(hostname -I | awk '{print $1}'):8888"
|
|
||||||
else
|
|
||||||
echo "⚠️ Service failed to start. Check logs:"
|
|
||||||
echo " journalctl -u sensors-http.service -f"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "📋 Management Commands:"
|
|
||||||
echo " Start: sudo systemctl start sensors-http"
|
|
||||||
echo " Stop: sudo systemctl stop sensors-http"
|
|
||||||
echo " Status: sudo systemctl status sensors-http"
|
|
||||||
echo " Logs: sudo journalctl -u sensors-http -f"
|
|
||||||
echo ""
|
|
||||||
echo "✅ Setup complete!"
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<!-- Modern, clean SVG icons for IPMI Controller -->
|
|
||||||
<svg style="display:none;" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
|
|
||||||
<!-- Thermometer Icon -->
|
|
||||||
<symbol id="icon-thermometer" viewBox="0 0 24 24">
|
|
||||||
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<circle cx="11.5" cy="18.5" r="2" fill="currentColor"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Server Icon -->
|
|
||||||
<symbol id="icon-server" viewBox="0 0 24 24">
|
|
||||||
<rect x="2" y="3" width="20" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<rect x="2" y="11" width="20" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<rect x="2" y="19" width="20" height="3" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<circle cx="6" cy="6" r="1" fill="currentColor"/>
|
|
||||||
<circle cx="6" cy="14" r="1" fill="currentColor"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Fan Icon -->
|
|
||||||
<symbol id="icon-fan" viewBox="0 0 24 24">
|
|
||||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M12 12c0-3 2-5 5-5s5 2 5 5-2 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M12 12c0 3-2 5-5 5s-5-2-5-5 2-5 5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M12 12c3 0 5-2 5-5s-2-5-5-5-5 2-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M12 12c-3 0-5 2-5 5s2 5 5 5 5-2 5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Clock/Mode Icon -->
|
|
||||||
<symbol id="icon-clock" viewBox="0 0 24 24">
|
|
||||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M12 7v5l3 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Sensors/List Icon -->
|
|
||||||
<symbol id="icon-sensors" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="7" cy="6" r="1.5" fill="currentColor"/>
|
|
||||||
<circle cx="7" cy="12" r="1.5" fill="currentColor"/>
|
|
||||||
<circle cx="7" cy="18" r="1.5" fill="currentColor"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Lock/Password Icon -->
|
|
||||||
<symbol id="icon-lock" viewBox="0 0 24 24">
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Logout Icon -->
|
|
||||||
<symbol id="icon-logout" viewBox="0 0 24 24">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M16 17l5-5-5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M21 12H9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Sun/Theme Icon -->
|
|
||||||
<symbol id="icon-sun" viewBox="0 0 24 24">
|
|
||||||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Controls/Settings Icon -->
|
|
||||||
<symbol id="icon-controls" viewBox="0 0 24 24">
|
|
||||||
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<circle cx="18" cy="6" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<circle cx="18" cy="18" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M9 12h3M15 6h-3M15 18h-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Search/Identify Icon -->
|
|
||||||
<symbol id="icon-search" viewBox="0 0 24 24">
|
|
||||||
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M21 21l-4.35-4.35" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- Check/Success Icon -->
|
|
||||||
<symbol id="icon-check" viewBox="0 0 24 24">
|
|
||||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<!-- X/Error Icon -->
|
|
||||||
<symbol id="icon-x" viewBox="0 0 24 24">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 429 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 254 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 365 B |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 279 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 667 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 281 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 267 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 490 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 501 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 395 B |
|
|
@ -1,11 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="M12 2v4"/>
|
|
||||||
<path d="M12 18v4"/>
|
|
||||||
<path d="M4.93 4.93l2.83 2.83"/>
|
|
||||||
<path d="M16.24 16.24l2.83 2.83"/>
|
|
||||||
<path d="M2 12h4"/>
|
|
||||||
<path d="M18 12h4"/>
|
|
||||||
<path d="M4.93 19.07l2.83-2.83"/>
|
|
||||||
<path d="M16.24 7.76l2.83-2.83"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 435 B |
|
|
@ -1 +0,0 @@
|
||||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.07"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>fan-blades</title><path class="cls-1" d="M67.29,82.9c-.11,1.3-.26,2.6-.47,3.9-1.43,9-5.79,14.34-8.08,22.17C56,118.45,65.32,122.53,73.27,122A37.63,37.63,0,0,0,85,119a45,45,0,0,0,9.32-5.36c20.11-14.8,16-34.9-6.11-46.36a15,15,0,0,0-4.14-1.4,22,22,0,0,1-6,11.07l0,0A22.09,22.09,0,0,1,67.29,82.9ZM62.4,44.22a17.1,17.1,0,1,1-17.1,17.1,17.1,17.1,0,0,1,17.1-17.1ZM84.06,56.83c1.26.05,2.53.14,3.79.29,9.06,1,14.58,5.16,22.5,7.1,9.6,2.35,13.27-7.17,12.41-15.09a37.37,37.37,0,0,0-3.55-11.57,45.35,45.35,0,0,0-5.76-9.08C97.77,9,77.88,14,67.4,36.63a14.14,14.14,0,0,0-1,2.94A22,22,0,0,1,78,45.68l0,0a22.07,22.07,0,0,1,6,11.13Zm-26.9-17c0-1.6.13-3.21.31-4.81,1-9.07,5.12-14.6,7-22.52C66.86,2.89,57.32-.75,49.41.13A37.4,37.4,0,0,0,37.84,3.7a44.58,44.58,0,0,0-9.06,5.78C9.37,25.2,14.39,45.08,37,55.51a14.63,14.63,0,0,0,3.76,1.14A22.12,22.12,0,0,1,57.16,39.83ZM40.66,65.42a52.11,52.11,0,0,1-5.72-.24c-9.08-.88-14.67-4.92-22.62-6.73C2.68,56.25-.83,65.84.16,73.74A37.45,37.45,0,0,0,3.9,85.25a45.06,45.06,0,0,0,5.91,9c16,19.17,35.8,13.87,45.91-8.91a15.93,15.93,0,0,0,.88-2.66A22.15,22.15,0,0,1,40.66,65.42Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.07"><path fill="#2196f3" d="M67.29,82.9c-.11,1.3-.26,2.6-.47,3.9-1.43,9-5.79,14.34-8.08,22.17C56,118.45,65.32,122.53,73.27,122A37.63,37.63,0,0,0,85,119a45,45,0,0,0,9.32-5.36c20.11-14.8,16-34.9-6.11-46.36a15,15,0,0,0-4.14-1.4,22,22,0,0,1-6,11.07l0,0A22.09,22.09,0,0,1,67.29,82.9ZM62.4,44.22a17.1,17.1,0,1,1-17.1,17.1,17.1,17.1,0,0,1,17.1-17.1ZM84.06,56.83c1.26.05,2.53.14,3.79.29,9.06,1,14.58,5.16,22.5,7.1,9.6,2.35,13.27-7.17,12.41-15.09a37.37,37.37,0,0,0-3.55-11.57,45.35,45.35,0,0,0-5.76-9.08C97.77,9,77.88,14,67.4,36.63a14.14,14.14,0,0,0-1,2.94A22,22,0,0,1,78,45.68l0,0a22.07,22.07,0,0,1,6,11.13Zm-26.9-17c0-1.6.13-3.21.31-4.81,1-9.07,5.12-14.6,7-22.52C66.86,2.89,57.32-.75,49.41.13A37.4,37.4,0,0,0,37.84,3.7a44.58,44.58,0,0,0-9.06,5.78C9.37,25.2,14.39,45.08,37,55.51a14.63,14.63,0,0,0,3.76,1.14A22.12,22.12,0,0,1,57.16,39.83ZM40.66,65.42a52.11,52.11,0,0,1-5.72-.24c-9.08-.88-14.67-4.92-22.62-6.73C2.68,56.25-.83,65.84.16,73.74A37.45,37.45,0,0,0,3.9,85.25a45.06,45.06,0,0,0,5.91,9c16,19.17,35.8,13.87,45.91-8.91a15.93,15.93,0,0,0,.88-2.66A22.15,22.15,0,0,1,40.66,65.42Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 540 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 694 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 371 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 394 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 591 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 0 1 0-5.304m5.304 0a3.75 3.75 0 0 1 0 5.304m-7.425 2.121a6.75 6.75 0 0 1 0-9.546m9.546 0a6.75 6.75 0 0 1 0 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 517 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 830 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 412 B |
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 242 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 3.75H6A2.25 2.25 0 0 0 3.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0 1 20.25 6v1.5m0 9V18A2.25 2.25 0 0 1 18 20.25h-1.5m-9 0H6A2.25 2.25 0 0 1 3.75 18v-1.5M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 406 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 396 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 288 B |
|
|
@ -0,0 +1 @@
|
||||||
|
# Tests for IPMI Fan Control
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Tests for authentication module."""
|
||||||
|
import pytest
|
||||||
|
from backend.auth import (
|
||||||
|
get_password_hash,
|
||||||
|
verify_password,
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
encrypt_password,
|
||||||
|
decrypt_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_hashing():
|
||||||
|
"""Test password hashing and verification."""
|
||||||
|
password = "testpassword123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hashed != password
|
||||||
|
assert verify_password(password, hashed)
|
||||||
|
assert not verify_password("wrongpassword", hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_tokens():
|
||||||
|
"""Test JWT token creation and decoding."""
|
||||||
|
data = {"sub": "testuser"}
|
||||||
|
token = create_access_token(data)
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
|
||||||
|
decoded = decode_access_token(token)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["sub"] == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token():
|
||||||
|
"""Test decoding invalid token."""
|
||||||
|
decoded = decode_access_token("invalid.token.here")
|
||||||
|
assert decoded is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_encryption():
|
||||||
|
"""Test password encryption and decryption."""
|
||||||
|
password = "secret_password"
|
||||||
|
encrypted = encrypt_password(password)
|
||||||
|
|
||||||
|
assert encrypted != password
|
||||||
|
|
||||||
|
decrypted = decrypt_password(encrypted)
|
||||||
|
assert decrypted == password
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Tests for fan curve logic."""
|
||||||
|
import pytest
|
||||||
|
from backend.fan_control import FanCurveManager, FanCurvePoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_curve():
|
||||||
|
"""Test parsing fan curve from JSON."""
|
||||||
|
json_data = '[{"temp": 30, "speed": 20}, {"temp": 50, "speed": 50}, {"temp": 70, "speed": 100}]'
|
||||||
|
curve = FanCurveManager.parse_curve(json_data)
|
||||||
|
|
||||||
|
assert len(curve) == 3
|
||||||
|
assert curve[0].temp == 30
|
||||||
|
assert curve[0].speed == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_invalid_curve():
|
||||||
|
"""Test parsing invalid curve returns default."""
|
||||||
|
curve = FanCurveManager.parse_curve("invalid json")
|
||||||
|
|
||||||
|
assert len(curve) == 6 # Default curve has 6 points
|
||||||
|
assert curve[0].temp == 30
|
||||||
|
assert curve[0].speed == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_speed_below_min():
|
||||||
|
"""Test speed calculation below minimum temperature."""
|
||||||
|
curve = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(50, 50),
|
||||||
|
FanCurvePoint(70, 100),
|
||||||
|
]
|
||||||
|
|
||||||
|
speed = FanCurveManager.calculate_speed(curve, 20)
|
||||||
|
assert speed == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_speed_above_max():
|
||||||
|
"""Test speed calculation above maximum temperature."""
|
||||||
|
curve = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(50, 50),
|
||||||
|
FanCurvePoint(70, 100),
|
||||||
|
]
|
||||||
|
|
||||||
|
speed = FanCurveManager.calculate_speed(curve, 80)
|
||||||
|
assert speed == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_speed_interpolation():
|
||||||
|
"""Test speed calculation with interpolation."""
|
||||||
|
curve = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(50, 50),
|
||||||
|
FanCurvePoint(70, 100),
|
||||||
|
]
|
||||||
|
|
||||||
|
# At 40°C, should be halfway between 10% and 50%
|
||||||
|
speed = FanCurveManager.calculate_speed(curve, 40)
|
||||||
|
assert speed == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_speed_exact_point():
|
||||||
|
"""Test speed calculation at exact curve point."""
|
||||||
|
curve = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(50, 50),
|
||||||
|
]
|
||||||
|
|
||||||
|
speed = FanCurveManager.calculate_speed(curve, 50)
|
||||||
|
assert speed == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_curve():
|
||||||
|
"""Test serializing curve to JSON."""
|
||||||
|
points = [
|
||||||
|
FanCurvePoint(30, 10),
|
||||||
|
FanCurvePoint(50, 50),
|
||||||
|
]
|
||||||
|
|
||||||
|
json_data = FanCurveManager.serialize_curve(points)
|
||||||
|
assert "30" in json_data
|
||||||
|
assert "10" in json_data
|
||||||
|
assert "50" in json_data
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Tests for IPMI client."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from backend.ipmi_client import IPMIClient, TemperatureReading
|
||||||
|
|
||||||
|
|
||||||
|
def test_fan_mapping():
|
||||||
|
"""Test fan ID mapping."""
|
||||||
|
assert IPMIClient.FAN_MAPPING["0x00"] == 1
|
||||||
|
assert IPMIClient.FAN_MAPPING["0x06"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_hex_to_percent_conversion():
|
||||||
|
"""Test hex to percent conversion mapping."""
|
||||||
|
assert IPMIClient.HEX_TO_PERCENT["0x00"] == 0
|
||||||
|
assert IPMIClient.HEX_TO_PERCENT["0x64"] == 100
|
||||||
|
assert IPMIClient.PERCENT_TO_HEX[50] == "0x32"
|
||||||
|
assert IPMIClient.PERCENT_TO_HEX[100] == "0x64"
|
||||||
|
|
||||||
|
|
||||||
|
def test_determine_temp_location():
|
||||||
|
"""Test temperature location detection."""
|
||||||
|
client = IPMIClient("192.168.1.1", "user", "pass")
|
||||||
|
|
||||||
|
assert client._determine_temp_location("CPU1 Temp") == "cpu1"
|
||||||
|
assert client._determine_temp_location("CPU2 Temp") == "cpu2"
|
||||||
|
assert client._determine_temp_location("Processor 1") == "cpu1"
|
||||||
|
assert client._determine_temp_location("Inlet Temp") == "inlet"
|
||||||
|
assert client._determine_temp_location("Exhaust Temp") == "exhaust"
|
||||||
|
assert client._determine_temp_location("DIMM 1") == "memory"
|
||||||
|
assert client._determine_temp_location("Unknown Sensor") == "other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_determine_sensor_type():
|
||||||
|
"""Test sensor type detection."""
|
||||||
|
client = IPMIClient("192.168.1.1", "user", "pass")
|
||||||
|
|
||||||
|
assert client._determine_sensor_type("CPU Temp") == "temperature"
|
||||||
|
assert client._determine_sensor_type("Fan 1 RPM") == "fan"
|
||||||
|
assert client._determine_sensor_type("12V") == "voltage"
|
||||||
|
assert client._determine_sensor_type("Power Supply") == "power"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sensor_value():
|
||||||
|
"""Test sensor value parsing."""
|
||||||
|
client = IPMIClient("192.168.1.1", "user", "pass")
|
||||||
|
|
||||||
|
value, unit = client._parse_sensor_value("45 degrees C")
|
||||||
|
assert value == 45.0
|
||||||
|
assert unit == "°C"
|
||||||
|
|
||||||
|
value, unit = client._parse_sensor_value("4200 RPM")
|
||||||
|
assert value == 4200.0
|
||||||
|
assert unit == "RPM"
|
||||||
|
|
||||||
|
value, unit = client._parse_sensor_value("12.05 Volts")
|
||||||
|
assert value == 12.05
|
||||||
|
assert unit == "V"
|
||||||
|
|
||||||
|
value, unit = client._parse_sensor_value("250 Watts")
|
||||||
|
assert value == 250.0
|
||||||
|
assert unit == "W"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('backend.ipmi_client.subprocess.run')
|
||||||
|
def test_test_connection(mock_run):
|
||||||
|
"""Test connection test method."""
|
||||||
|
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
client = IPMIClient("192.168.1.1", "user", "pass")
|
||||||
|
result = client.test_connection()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('backend.ipmi_client.subprocess.run')
|
||||||
|
def test_test_connection_failure(mock_run):
|
||||||
|
"""Test connection test with failure."""
|
||||||
|
mock_run.return_value = Mock(returncode=1, stdout="", stderr="Error")
|
||||||
|
|
||||||
|
client = IPMIClient("192.168.1.1", "user", "pass")
|
||||||
|
result = client.test_connection()
|
||||||
|
|
||||||
|
assert result is False
|
||||||