ipmi-fan-control/web_server.py

696 lines
25 KiB
Python

"""
Web API for IPMI Fan Controller v2
"""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional, List, Dict
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from fan_controller import get_service, FanControlService, IPMIFanController
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Pydantic models
class ConfigUpdate(BaseModel):
host: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
port: Optional[int] = 623
enabled: Optional[bool] = None
interval: Optional[int] = Field(None, ge=5, le=300)
min_speed: Optional[int] = Field(None, ge=0, le=100)
max_speed: Optional[int] = Field(None, ge=0, le=100)
panic_temp: Optional[float] = Field(None, ge=50, le=100)
panic_speed: Optional[int] = Field(None, ge=0, le=100)
class FanCurvePoint(BaseModel):
temp: float = Field(..., ge=0, le=100)
speed: int = Field(..., ge=0, le=100)
class FanCurveUpdate(BaseModel):
points: List[FanCurvePoint]
class ManualSpeedRequest(BaseModel):
speed: int = Field(..., ge=0, le=100)
class StatusResponse(BaseModel):
running: bool
enabled: bool
connected: bool
manual_mode: bool
current_speed: int
target_speed: int
temperatures: List[Dict]
fans: List[Dict]
# Create static directory and HTML
STATIC_DIR = Path(__file__).parent / "static"
STATIC_DIR.mkdir(exist_ok=True)
# Create the HTML dashboard
DASHBOARD_HTML = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IPMI Fan Controller v2</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 10px; font-size: 1.8rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; }
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.status-item {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
text-align: center;
}
.status-item .label { font-size: 0.85rem; color: #888; margin-bottom: 5px; }
.status-item .value { font-size: 1.5rem; font-weight: bold; }
.status-item .value.good { color: #4caf50; }
.status-item .value.warn { color: #ff9800; }
.status-item .value.bad { color: #f44336; }
.temp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.temp-item {
display: flex;
justify-content: space-between;
padding: 10px 15px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
}
.temp-item .temp-value { font-weight: bold; }
.temp-item .temp-value.high { color: #f44336; }
.temp-item .temp-value.med { color: #ff9800; }
.temp-item .temp-value.low { color: #4caf50; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; }
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
button.primary { background: #2196f3; color: white; }
button.primary:hover { background: #1976d2; }
button.success { background: #4caf50; color: white; }
button.success:hover { background: #388e3c; }
button.danger { background: #f44336; color: white; }
button.danger:hover { background: #d32f2f; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.slider-container { margin: 15px 0; }
.slider-container label { display: block; margin-bottom: 10px; }
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(255,255,255,0.1);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
background: #2196f3;
border-radius: 50%;
cursor: pointer;
}
.config-form {
display: grid;
gap: 15px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-size: 0.9rem;
color: #aaa;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 1rem;
}
.log-output {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 6px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
color: white;
animation: slideIn 0.3s ease;
z-index: 1000;
}
.toast.success { background: #4caf50; }
.toast.error { background: #f44336; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="container">
<h1>🌬️ IPMI Fan Controller v2</h1>
<p class="subtitle">Dell T710 & Compatible Servers</p>
<div class="card">
<h2>📊 Current Status</h2>
<div class="status-grid">
<div class="status-item">
<div class="label">Connection</div>
<div class="value" id="conn-status">-</div>
</div>
<div class="status-item">
<div class="label">Control Mode</div>
<div class="value" id="mode-status">-</div>
</div>
<div class="status-item">
<div class="label">Current Speed</div>
<div class="value" id="current-speed">-</div>
</div>
<div class="status-item">
<div class="label">Max Temp</div>
<div class="value" id="max-temp">-</div>
</div>
</div>
<div id="temp-section" style="display:none;">
<h3 style="margin:15px 0 10px;font-size:1rem;color:#aaa;">Temperatures</h3>
<div class="temp-grid" id="temp-list"></div>
</div>
</div>
<div class="card">
<h2>🎛️ Quick Controls</h2>
<div class="controls">
<button class="success" id="btn-auto" onclick="setAuto(true)">▶ Start Auto</button>
<button class="danger" id="btn-stop" onclick="setAuto(false)">⏹ Stop Auto</button>
<button class="primary" onclick="testConnection()">🔄 Test Connection</button>
</div>
<div class="slider-container">
<label>Manual Fan Speed: <strong id="manual-speed-val">50%</strong></label>
<input type="range" id="manual-speed" min="0" max="100" value="50">
<button class="primary" style="margin-top:10px;" onclick="setManualSpeed()">Apply Manual Speed</button>
</div>
</div>
<div class="card">
<h2>⚙️ Configuration</h2>
<div class="config-form">
<div class="form-row">
<div class="form-group">
<label>IPMI Host/IP</label>
<input type="text" id="cfg-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="cfg-port" value="623">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="cfg-username" placeholder="root">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="cfg-password" placeholder="••••••••">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Min Speed (%)</label>
<input type="number" id="cfg-min" value="10" min="0" max="100">
</div>
<div class="form-group">
<label>Max Speed (%)</label>
<input type="number" id="cfg-max" value="100" min="0" max="100">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Panic Temp (°C)</label>
<input type="number" id="cfg-panic-temp" value="85" min="50" max="100">
</div>
<div class="form-group">
<label>Check Interval (sec)</label>
<input type="number" id="cfg-interval" value="10" min="5" max="300">
</div>
</div>
<button class="primary" onclick="saveConfig()">💾 Save Configuration</button>
</div>
</div>
<div class="card">
<h2>📈 Fan Curve</h2>
<div id="curve-editor" style="margin-bottom:15px;">
<p style="color:#888;margin-bottom:10px;">Temp (°C) → Speed (%)</p>
<div id="curve-points"></div>
</div>
<button class="primary" onclick="saveCurve()">Save Fan Curve</button>
</div>
<div class="card">
<h2>📝 Logs</h2>
<div class="log-output" id="logs">Ready...</div>
<button class="primary" style="margin-top:10px;" onclick="clearLogs()">Clear</button>
</div>
</div>
<script>
let currentStatus = {};
// Update slider display
document.getElementById('manual-speed').addEventListener('input', (e) => {
document.getElementById('manual-speed-val').textContent = e.target.value + '%';
});
async function fetchStatus() {
try {
const res = await fetch('/api/status');
currentStatus = await res.json();
updateUI();
} catch (e) {
log('Failed to fetch status: ' + e.message, 'error');
}
}
function updateUI() {
// Connection status
const connEl = document.getElementById('conn-status');
if (currentStatus.connected) {
connEl.textContent = '✓ Connected';
connEl.className = 'value good';
} else {
connEl.textContent = '✗ Disconnected';
connEl.className = 'value bad';
}
// Mode
const modeEl = document.getElementById('mode-status');
if (currentStatus.enabled) {
modeEl.textContent = 'AUTO';
modeEl.className = 'value good';
} else if (currentStatus.manual_mode) {
modeEl.textContent = 'MANUAL';
modeEl.className = 'value warn';
} else {
modeEl.textContent = 'AUTO (BIOS)';
modeEl.className = 'value';
}
// Speed
document.getElementById('current-speed').textContent = currentStatus.current_speed + '%';
// Temperatures
const temps = currentStatus.temperatures || [];
const cpuTemps = temps.filter(t => t.location.includes('cpu'));
if (cpuTemps.length > 0) {
const maxTemp = Math.max(...cpuTemps.map(t => t.value));
const tempEl = document.getElementById('max-temp');
tempEl.textContent = maxTemp.toFixed(1) + '°C';
tempEl.className = 'value ' + (maxTemp > 70 ? 'bad' : maxTemp > 50 ? 'warn' : 'good');
}
// Temp list
const tempList = document.getElementById('temp-list');
tempList.innerHTML = temps.map(t => {
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'med' : 'low';
return `<div class="temp-item">
<span>${t.name}</span>
<span class="temp-value ${cls}">${t.value.toFixed(1)}°C</span>
</div>`;
}).join('');
document.getElementById('temp-section').style.display = temps.length ? 'block' : 'none';
// Update config fields if empty
if (currentStatus.config) {
const cfg = currentStatus.config;
if (!document.getElementById('cfg-host').value && cfg.host)
document.getElementById('cfg-host').value = cfg.host;
if (!document.getElementById('cfg-username').value && cfg.username)
document.getElementById('cfg-username').value = cfg.username;
if (cfg.port) document.getElementById('cfg-port').value = cfg.port;
if (cfg.min_speed !== undefined) document.getElementById('cfg-min').value = cfg.min_speed;
if (cfg.max_speed !== undefined) document.getElementById('cfg-max').value = cfg.max_speed;
if (cfg.panic_temp) document.getElementById('cfg-panic-temp').value = cfg.panic_temp;
if (cfg.interval) document.getElementById('cfg-interval').value = cfg.interval;
}
// Update curve editor
updateCurveEditor();
}
function updateCurveEditor() {
const curve = (currentStatus.config?.fan_curve) || [
{temp: 30, speed: 15}, {temp: 40, speed: 25}, {temp: 50, speed: 40},
{temp: 60, speed: 60}, {temp: 70, speed: 80}, {temp: 80, speed: 100}
];
const container = document.getElementById('curve-points');
container.innerHTML = curve.map((p, i) => `
<div class="form-row" style="margin-bottom:8px;">
<input type="number" class="curve-temp" data-idx="${i}" value="${p.temp}" min="0" max="100" placeholder="Temp">
<input type="number" class="curve-speed" data-idx="${i}" value="${p.speed}" min="0" max="100" placeholder="Speed %">
</div>
`).join('');
}
async function testConnection() {
log('Testing IPMI connection...');
try {
const res = await fetch('/api/test', { method: 'POST' });
const data = await res.json();
if (data.success) {
log('✓ Connection successful', 'success');
} else {
log('✗ Connection failed: ' + data.error, 'error');
}
} catch (e) {
log('Error: ' + e.message, 'error');
}
}
async function setAuto(enabled) {
try {
const res = await fetch('/api/control/auto', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled})
});
const data = await res.json();
if (data.success) {
log(enabled ? 'Auto control enabled' : 'Auto control disabled', 'success');
fetchStatus();
} else {
log('Failed: ' + data.error, 'error');
}
} catch (e) {
log('Error: ' + e.message, 'error');
}
}
async function setManualSpeed() {
const speed = parseInt(document.getElementById('manual-speed').value);
try {
const res = await fetch('/api/control/manual', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speed})
});
const data = await res.json();
if (data.success) {
log(`Manual speed set to ${speed}%`, 'success');
fetchStatus();
} else {
log('Failed: ' + data.error, 'error');
}
} catch (e) {
log('Error: ' + e.message, 'error');
}
}
async function saveConfig() {
const config = {
host: document.getElementById('cfg-host').value,
port: parseInt(document.getElementById('cfg-port').value),
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value || undefined,
min_speed: parseInt(document.getElementById('cfg-min').value),
max_speed: parseInt(document.getElementById('cfg-max').value),
panic_temp: parseFloat(document.getElementById('cfg-panic-temp').value),
interval: parseInt(document.getElementById('cfg-interval').value)
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
const data = await res.json();
if (data.success) {
log('Configuration saved', 'success');
document.getElementById('cfg-password').value = ''; // Clear password
fetchStatus();
} else {
log('Failed: ' + data.error, 'error');
}
} catch (e) {
log('Error: ' + e.message, 'error');
}
}
async function saveCurve() {
const points = [];
document.querySelectorAll('.curve-temp').forEach((el, i) => {
const temp = parseFloat(el.value);
const speedEl = document.querySelector(`.curve-speed[data-idx="${i}"]`);
const speed = parseInt(speedEl.value);
points.push({temp, speed});
});
try {
const res = await fetch('/api/config/curve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({points})
});
const data = await res.json();
if (data.success) {
log('Fan curve saved', 'success');
fetchStatus();
} else {
log('Failed: ' + data.error, 'error');
}
} catch (e) {
log('Error: ' + e.message, 'error');
}
}
function log(msg, type='info') {
const logs = document.getElementById('logs');
const time = new Date().toLocaleTimeString();
logs.textContent += `[${time}] ${msg}\\n`;
logs.scrollTop = logs.scrollHeight;
if (type === 'success' || type === 'error') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
}
function clearLogs() {
document.getElementById('logs').textContent = '';
}
// Auto-refresh
setInterval(fetchStatus, 3000);
fetchStatus();
</script>
</body>
</html>
'''
# Write the HTML file
(STATIC_DIR / "index.html").write_text(DASHBOARD_HTML)
# FastAPI app
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
service = get_service()
# Auto-start if configured
if service.config.get('enabled') and service.config.get('host'):
service.start()
yield
service.stop()
app = FastAPI(
title="IPMI Fan Controller v2",
description="Simple, robust fan control for Dell servers",
version="2.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the dashboard."""
return HTMLResponse(content=DASHBOARD_HTML)
@app.get("/api/status", response_model=StatusResponse)
async def get_status():
"""Get current controller status."""
service = get_service()
return service.get_status()
@app.post("/api/test")
async def test_connection():
"""Test IPMI connection."""
service = get_service()
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Failed to initialize controller"}
success = service.controller.test_connection()
return {"success": success, "error": None if success else "Connection failed"}
@app.post("/api/config")
async def update_config(update: ConfigUpdate):
"""Update configuration."""
service = get_service()
updates = {k: v for k, v in update.model_dump().items() if v is not None}
if not updates:
return {"success": False, "error": "No valid updates provided"}
# Require password if host/username changed and no new password provided
if ('host' in updates or 'username' in updates) and 'password' not in updates:
if not service.config.get('password'):
return {"success": False, "error": "Password required when setting host/username"}
try:
service.update_config(**updates)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@app.post("/api/config/curve")
async def update_curve(curve: FanCurveUpdate):
"""Update fan curve."""
service = get_service()
points = [{"temp": p.temp, "speed": p.speed} for p in curve.points]
service.update_config(fan_curve=points)
return {"success": True}
@app.post("/api/control/auto")
async def set_auto_control(data: dict):
"""Enable/disable automatic control."""
service = get_service()
enabled = data.get('enabled', False)
if enabled and not service.config.get('host'):
return {"success": False, "error": "IPMI host not configured"}
service.set_auto_mode(enabled)
if enabled and not service.running:
if not service.start():
return {"success": False, "error": "Failed to start service"}
return {"success": True}
@app.post("/api/control/manual")
async def set_manual_control(req: ManualSpeedRequest):
"""Set manual fan speed."""
service = get_service()
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Failed to connect to server"}
if service.set_manual_speed(req.speed):
return {"success": True}
return {"success": False, "error": "Failed to set fan speed"}
@app.post("/api/shutdown")
async def shutdown():
"""Return fans to automatic control and stop service."""
service = get_service()
service.stop()
return {"success": True, "message": "Service stopped, fans returned to automatic control"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)