ipmi-fan-control/web_server.py

1980 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
IPMI Controller Web Server
Advanced web interface with dark mode, fan groups, multiple curves
"""
import asyncio
import json
import logging
import hashlib
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional, List, Dict
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
import sys
sys.path.insert(0, str(Path(__file__).parent))
from fan_controller import get_service, IPMIControllerService
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
# Data directories
DATA_DIR = Path("/app/data") if Path("/app/data").exists() else Path(__file__).parent / "data"
DATA_DIR.mkdir(exist_ok=True)
CONFIG_FILE = DATA_DIR / "config.json"
USERS_FILE = DATA_DIR / "users.json"
# Pydantic models
class UserLogin(BaseModel):
username: str
password: str
class ChangePassword(BaseModel):
current_password: str
new_password: str
class SetupRequest(BaseModel):
admin_username: str
admin_password: str
ipmi_host: str
ipmi_username: str
ipmi_password: str
ipmi_port: int = 623
http_sensor_enabled: Optional[bool] = False
http_sensor_url: Optional[str] = None
http_sensor_timeout: Optional[int] = 10
enabled: Optional[bool] = False
active_curve: Optional[str] = "Balanced"
class IPMIConfig(BaseModel):
host: str
username: str
password: Optional[str] = None
port: int = 623
class HTTPConfig(BaseModel):
enabled: bool = False
url: Optional[str] = None
timeout: int = 10
class FanSettings(BaseModel):
enabled: Optional[bool] = None
poll_interval: Optional[int] = Field(None, ge=5, le=300)
fan_update_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)
panic_on_no_data: Optional[bool] = None
no_data_timeout: Optional[int] = Field(None, ge=10, le=300)
primary_sensor: Optional[str] = None
sensor_preference: Optional[str] = None
theme: Optional[str] = None
class FanCurvePoint(BaseModel):
temp: float = Field(..., ge=0, le=100)
speed: int = Field(..., ge=0, le=100)
class FanCurveCreate(BaseModel):
name: str
points: List[FanCurvePoint]
sensor_source: str = "cpu"
applies_to: str = "all"
class FanConfig(BaseModel):
fan_id: str
name: Optional[str] = None
group: Optional[str] = None
curve: Optional[str] = None
class FanGroupCreate(BaseModel):
name: str
fans: List[str]
curve: str = "Default"
class ManualSpeedRequest(BaseModel):
speed: int = Field(..., ge=0, le=100)
fan_id: Optional[str] = "0xff"
group: Optional[str] = None
class IdentifyRequest(BaseModel):
fan_id: str
# User Manager
class UserManager:
def __init__(self):
self.users_file = USERS_FILE
self._users = {}
self._sessions = {}
self._load()
def _load(self):
if self.users_file.exists():
try:
with open(self.users_file) as f:
data = json.load(f)
self._users = data.get('users', {})
except Exception as e:
logger.error(f"Failed to load users: {e}")
def _save(self):
with open(self.users_file, 'w') as f:
json.dump({'users': self._users}, f)
def _hash(self, password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def verify(self, username: str, password: str) -> bool:
return username in self._users and self._users[username] == self._hash(password)
def create(self, username: str, password: str) -> bool:
if username in self._users:
return False
self._users[username] = self._hash(password)
self._save()
return True
def change_password(self, username: str, current: str, new: str) -> bool:
if not self.verify(username, current):
return False
self._users[username] = self._hash(new)
self._save()
return True
def create_token(self, username: str) -> str:
token = secrets.token_urlsafe(32)
self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7))
return token
def verify_token(self, token: str) -> Optional[str]:
if token not in self._sessions:
return None
username, expiry = self._sessions[token]
if datetime.utcnow() > expiry:
del self._sessions[token]
return None
return username
def is_setup_complete(self) -> bool:
return len(self._users) > 0
user_manager = UserManager()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
username = user_manager.verify_token(credentials.credentials)
if not username:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return username
# CSS Themes
THEMES = {
"dark": """
:root {
--bg-primary: #0f0f1e;
--bg-secondary: #1a1a2e;
--bg-card: rgba(255,255,255,0.05);
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--accent-primary: #2196f3;
--accent-success: #4caf50;
--accent-warning: #ff9800;
--accent-danger: #f44336;
--border: rgba(255,255,255,0.1);
}
""",
"light": """
:root {
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--accent-primary: #1976d2;
--accent-success: #388e3c;
--accent-warning: #f57c00;
--accent-danger: #d32f2f;
--border: #e0e0e0;
}
body {
background: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
.card {
background: var(--bg-card) !important;
border-color: var(--border) !important;
}
input, select, textarea {
background: #f0f0f0 !important;
color: #333 !important;
border-color: #ccc !important;
}
.status-item .icon img, .icon-svg, .icon-logo {
filter: none !important;
}
"""
}
# HTML Template
def get_html(theme="dark"):
theme_css = THEMES.get(theme, THEMES["dark"])
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.ico">
<title>IPMI Controller</title>
<style>
{theme_css}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
min-height: 100vh;
color: var(--text-primary);
}}
.container {{ max-width: 1200px; margin: 0 auto; padding: 20px; }}
/* Header */
header {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}}
.header-top {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 15px;
}}
h1 {{ font-size: 1.5rem; display: flex; align-items: center; gap: 10px; }}
.header-actions {{ display: flex; gap: 10px; }}
/* Status Bar */
.status-bar {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
}}
.status-item {{
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
text-align: center;
transition: all 0.2s;
}}
.status-item:hover {{ transform: translateY(-2px); }}
.status-item .icon {{ width: 32px; height: 32px; margin: 0 auto 8px; }}
.status-item .icon img {{ width: 100%; height: 100%; filter: brightness(0) invert(1); image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast; }}
.icon-svg {{ width: 20px; height: 20px; display: inline-block; vertical-align: middle; margin-right: 6px; filter: brightness(0) invert(1); image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast; }}
.icon-logo {{ width: 32px; height: 32px; display: inline-block; vertical-align: middle; margin-right: 8px; filter: brightness(0) invert(1); image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast; }}
.status-item .label {{ font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 3px; }}
.status-item .value {{ font-size: 1.1rem; font-weight: bold; }}
.status-item .value.good {{ color: var(--accent-success); }}
.status-item .value.warn {{ color: var(--accent-warning); }}
.status-item .value.danger {{ color: var(--accent-danger); }}
/* Cards */
.card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}}
.card h2 {{
font-size: 1.1rem;
color: var(--accent-primary);
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}}
/* Buttons */
button {{
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}}
button:hover {{ opacity: 0.9; transform: translateY(-1px); }}
button.primary {{ background: var(--accent-primary); color: white; }}
button.success {{ background: var(--accent-success); color: white; }}
button.danger {{ background: var(--accent-danger); color: white; }}
button.secondary {{
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border);
}}
button.small {{ padding: 6px 12px; font-size: 0.8rem; }}
/* Forms */
.form-group {{ margin-bottom: 15px; }}
.form-group label {{
display: block;
margin-bottom: 5px;
font-size: 0.85rem;
color: var(--text-secondary);
}}
input, select, textarea {{
width: 100%;
padding: 10px;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.95rem;
}}
.form-row {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}}
/* Tabs */
.tabs {{
display: flex;
gap: 5px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
flex-wrap: wrap;
}}
.tab {{
padding: 8px 16px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
}}
.tab:hover {{ color: var(--text-primary); }}
.tab.active {{ background: rgba(255,255,255,0.1); color: var(--text-primary); }}
.tab-content {{ display: none; }}
.tab-content.active {{ display: block; }}
/* Fan Grid */
.fan-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}}
.fan-card {{
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border);
}}
.fan-card .fan-name {{ font-weight: bold; margin-bottom: 5px; }}
.fan-card .fan-info {{ font-size: 0.85rem; color: var(--text-secondary); }}
/* Temp Grid */
.temp-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 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: var(--accent-danger); }}
.temp-item .temp-value.medium {{ color: var(--accent-warning); }}
.temp-item .temp-value.low {{ color: var(--accent-success); }}
/* Curve Editor */
.curve-point {{
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
margin-bottom: 8px;
}}
/* Logs */
.log-output {{
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 6px;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}}
/* Modal */
.modal {{
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}}
.modal.active {{ display: flex; }}
.modal-content {{
background: var(--bg-secondary);
padding: 30px;
border-radius: 12px;
max-width: 500px;
width: 100%;
border: 1px solid var(--border);
max-height: 90vh;
overflow-y: auto;
}}
/* Toast */
.toast {{
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
color: white;
animation: slideIn 0.3s ease;
z-index: 1001;
}}
.toast.success {{ background: var(--accent-success); }}
.toast.error {{ background: var(--accent-danger); }}
@keyframes slideIn {{
from {{ transform: translateX(100%); opacity: 0; }}
to {{ transform: translateX(0); opacity: 1; }}
}}
.hidden {{ display: none !important; }}
@media (max-width: 600px) {{
.form-row {{ grid-template-columns: 1fr; }}
.status-bar {{ grid-template-columns: repeat(2, 1fr); }}
.header-top {{ flex-direction: column; align-items: flex-start; }}
}}
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-top">
<h1><img src="/icons/favicon.svg" class="icon-logo" alt=""> IPMI Controller</h1>
<div class="header-actions">
<button class="secondary small" onclick="toggleTheme()"><img src="/icons/sun.svg" class="icon-svg" alt=""> Theme</button>
<button class="secondary small" onclick="showPasswordModal()"><img src="/icons/lock-closed.svg" class="icon-svg" alt=""> Password</button>
<button class="secondary small" onclick="logout()"><img src="/icons/arrow-right-on-rectangle.svg" class="icon-svg" alt=""> Logout</button>
</div>
</div>
<div class="status-bar">
<div class="status-item" id="status-ipmi">
<div class="icon"><img src="/icons/server-stack.svg" alt=""></div>
<div class="label">IPMI</div>
<div class="value" id="val-ipmi">-</div>
</div>
<div class="status-item" id="status-mode">
<div class="icon"><img src="/icons/auto-mode.svg" alt=""></div>
<div class="label">Mode</div>
<div class="value" id="val-mode">-</div>
</div>
<div class="status-item" id="status-temp">
<div class="icon"><img src="/icons/thermometer.svg" alt=""></div>
<div class="label">Max Temp</div>
<div class="value" id="val-temp">-</div>
</div>
<div class="status-item" id="status-fans">
<div class="icon"><img src="/icons/favicon.svg" alt=""></div>
<div class="label">Fan Speed</div>
<div class="value" id="val-fans">-</div>
</div>
<div class="status-item" id="status-sensors">
<div class="icon"><img src="/icons/list-bullet.svg" alt=""></div>
<div class="label">Sensors</div>
<div class="value" id="val-sensors">-</div>
</div>
</div>
</header>
<!-- Quick Controls -->
<div class="card">
<h2><img src="/icons/adjustments-horizontal.svg" class="icon-svg" alt=""> Quick Controls</h2>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:15px;">
<button class="success" onclick="setAuto(true)">Start Auto</button>
<button class="danger" onclick="setAuto(false)">Stop Auto</button>
<button class="primary" onclick="testConnection()">Test Connection</button>
<button class="secondary" onclick="showIdentifyModal()">Identify Fan</button>
</div>
<div class="form-group">
<label>Manual Speed: <strong id="manual-val">50%</strong></label>
<input type="range" id="manual-speed" min="0" max="100" value="50"
oninput="document.getElementById('manual-val').textContent = this.value + '%'">
<button class="primary" style="margin-top:10px;" onclick="setManualSpeed()">Apply Manual</button>
</div>
</div>
<!-- Temperatures -->
<div class="card">
<h2><img src="/icons/thermometer.svg" class="icon-svg" alt=""> Temperatures</h2>
<div id="temp-container">
<div id="temp-cpu1" style="margin-bottom:15px;"></div>
<div id="temp-cpu2" style="margin-bottom:15px;"></div>
<div id="temp-nvme" style="margin-bottom:15px;"></div>
<div id="temp-raid" style="margin-bottom:15px;"></div>
<div id="temp-ambient" style="margin-bottom:15px;"></div>
<div id="temp-other"></div>
</div>
</div>
<!-- Fans -->
<div class="card">
<h2><img src="/icons/favicon.svg" class="icon-svg" alt=""> Fans</h2>
<div class="fan-grid" id="fan-grid">
<div class="fan-card">Loading...</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="tabs">
<button class="tab active" onclick="showTab('ipmi')"><span class="icon-svg"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="6" rx="2"/><rect x="2" y="11" width="20" height="6" rx="2"/><rect x="2" y="19" width="20" height="3" rx="1.5"/></svg></span>IPMI</button>
<button class="tab" onclick="showTab('http')"><span class="icon-svg"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>HTTP</button>
<button class="tab" onclick="showTab('control')"><span class="icon-svg"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>Control</button>
<button class="tab" onclick="showTab('fans')"><img src="/icons/favicon.svg" class="icon-svg" alt=""> Fan Groups</button>
<button class="tab" onclick="showTab('curves')"><img src="/icons/chart-bar.svg" class="icon-svg" alt=""> Curves</button>
<button class="tab" onclick="showTab('logs')"><img src="/icons/document-text.svg" class="icon-svg" alt=""> Logs</button>
</div>
<div id="tab-ipmi" class="tab-content active">
<h3>IPMI Configuration</h3>
<div class="form-row">
<div class="form-group">
<label>Host/IP</label>
<input type="text" id="cfg-ipmi-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="cfg-ipmi-port" value="623">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="cfg-ipmi-user" placeholder="root">
</div>
<div class="form-group">
<label>Password (blank = unchanged)</label>
<input type="password" id="cfg-ipmi-pass">
</div>
</div>
<button class="primary" onclick="saveIPMI()">Save IPMI</button>
</div>
<div id="tab-http" class="tab-content">
<h3>HTTP Sensor (lm-sensors over HTTP)</h3>
<div class="form-group">
<label><input type="checkbox" id="cfg-http-enabled" style="width:auto;margin-right:8px;"> Enable HTTP Sensor</label>
</div>
<div class="form-group">
<label>URL (e.g., http://192.168.1.100:8888)</label>
<input type="text" id="cfg-http-url" placeholder="http://server:8888">
</div>
<div class="form-group">
<label>Timeout (seconds)</label>
<input type="number" id="cfg-http-timeout" value="10">
</div>
<button class="primary" onclick="saveHTTP()">Save HTTP</button>
</div>
<div id="tab-control" class="tab-content">
<h3>Fan Control Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Poll Interval (sec)</label>
<input type="number" id="cfg-poll" value="10" min="5" max="300">
</div>
<div class="form-group">
<label>Primary Sensor</label>
<select id="cfg-sensor">
<option value="cpu">CPU (All)</option>
<option value="cpu1">CPU 1</option>
<option value="cpu2">CPU 2</option>
<option value="inlet">Inlet</option>
<option value="exhaust">Exhaust</option>
<option value="pcie">PCIe</option>
</select>
</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">
</div>
<div class="form-group">
<label>Panic Speed (%)</label>
<input type="number" id="cfg-panic-speed" value="100">
</div>
</div>
<div class="form-group">
<label><input type="checkbox" id="cfg-panic-no-data" style="width:auto;margin-right:8px;"> Panic on No Data</label>
</div>
<button class="primary" onclick="saveControl()">Save Settings</button>
</div>
<div id="tab-fans" class="tab-content">
<h3>Fan Groups Management</h3>
<div id="fan-groups-list" style="margin-bottom:20px;">
<p style="color:var(--text-secondary);">Loading fan groups...</p>
</div>
<h4>Individual Fan Configuration</h4>
<div id="fan-config-list">
<p style="color:var(--text-secondary);">Connect to IPMI to see fans</p>
</div>
<h4 style="margin-top:20px;">Quick Actions</h4>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="secondary" onclick="setAllFans(50)">Set All 50%</button>
<button class="secondary" onclick="setAllFans(75)">Set All 75%</button>
<button class="secondary" onclick="setAllFans(100)">Set All 100%</button>
</div>
</div>
<div id="tab-curves" class="tab-content">
<h3>Fan Curves</h3>
<div id="curves-list"></div>
<button class="secondary" onclick="showCurveModal()">+ Add Curve</button>
</div>
<div id="tab-logs" class="tab-content">
<h3>System Logs</h3>
<div class="log-output" id="logs"></div>
<button class="secondary small" style="margin-top:10px;" onclick="clearLogs()">Clear</button>
</div>
</div>
</div>
<!-- Modals -->
<div class="modal" id="password-modal">
<div class="modal-content">
<h3>Change Password</h3>
<div class="form-group"><label>Current</label><input type="password" id="pwd-current"></div>
<div class="form-group"><label>New</label><input type="password" id="pwd-new"></div>
<div class="form-group"><label>Confirm</label><input type="password" id="pwd-confirm"></div>
<div style="display:flex;gap:10px;">
<button class="secondary" style="flex:1;" onclick="hideModal('password-modal')">Cancel</button>
<button class="primary" style="flex:1;" onclick="changePassword()">Change</button>
</div>
</div>
</div>
<div class="modal" id="identify-modal">
<div class="modal-content">
<h3><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M10 2a8 8 0 105.29 14.29l4.91 4.91a1 1 0 001.41-1.41l-4.91-4.91A8 8 0 0010 2zm0 2a6 6 0 110 12 6 6 0 010-12z"/></svg></span>Identify Fan</h3>
<p style="margin-bottom:15px;color:var(--text-secondary);">Select a fan to identify it (sets that fan to 100%, others to 0%)</p>
<div id="identify-fan-list"></div>
<div style="display:flex;gap:10px;margin-top:15px;">
<button class="secondary" style="flex:1;" onclick="hideModal('identify-modal')">Close</button>
<button class="danger" style="flex:1;" onclick="stopIdentify()">Stop Identify</button>
</div>
</div>
</div>
<!-- Fan Group Modal -->
<div class="modal" id="group-modal">
<div class="modal-content">
<h3>Manage Fan Group</h3>
<div class="form-group">
<label>Group Name</label>
<input type="text" id="group-name" placeholder="e.g., CPU Fans, Rear Fans">
</div>
<div class="form-group">
<label>Select Fans for Group</label>
<div id="group-fan-selection" style="max-height:200px;overflow-y:auto;">
<p style="color:var(--text-secondary);">Connect to IPMI to see fans</p>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:15px;">
<button class="secondary" style="flex:1;" onclick="hideModal('group-modal')">Cancel</button>
<button class="primary" style="flex:1;" onclick="saveGroup()">Save Group</button>
</div>
</div>
</div>
<!-- Per-Fan Speed Modal -->
<div class="modal" id="fan-speed-modal">
<div class="modal-content">
<h3>Set Fan Speed</h3>
<div id="fan-speed-target" style="margin-bottom:15px;font-weight:bold;"></div>
<div class="form-group">
<label>Speed: <span id="fan-speed-val">50%</span></label>
<input type="range" id="fan-speed-slider" min="0" max="100" value="50"
oninput="document.getElementById('fan-speed-val').textContent = this.value + '%'">
</div>
<div style="display:flex;gap:10px;margin-top:15px;">
<button class="secondary" style="flex:1;" onclick="hideModal('fan-speed-modal')">Cancel</button>
<button class="primary" style="flex:1;" onclick="applyFanSpeed()">Apply</button>
</div>
</div>
</div>
<!-- Add/Edit Curve Modal -->
<div class="modal" id="curve-modal">
<div class="modal-content" style="max-width:500px;">
<h3>Fan Curve Editor</h3>
<div class="form-group">
<label>Curve Name</label>
<input type="text" id="curve-name" placeholder="e.g., Silent, Performance, Balanced">
</div>
<div class="form-group">
<label>Assign to Group (optional)</label>
<select id="curve-group">
<option value="">All Fans</option>
</select>
</div>
<h4 style="margin-top:15px;">Curve Points (Temp → Speed)</h4>
<div id="curve-points">
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="30">
<input type="number" class="curve-speed" placeholder="Speed %" value="20">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="50">
<input type="number" class="curve-speed" placeholder="Speed %" value="50">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="70">
<input type="number" class="curve-speed" placeholder="Speed %" value="100">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
</div>
<button class="secondary small" onclick="addCurvePoint()" style="margin-bottom:15px;">+ Add Point</button>
<div style="display:flex;gap:10px;">
<button class="secondary" style="flex:1;" onclick="hideModal('curve-modal')">Cancel</button>
<button class="primary" style="flex:1;" onclick="saveCurve()">Save Curve</button>
</div>
</div>
</div>
<script>
let currentStatus = {{}};
let currentConfig = {{}};
// Theme
function toggleTheme() {{
const current = localStorage.getItem('theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
window.location.href = window.location.pathname + '?theme=' + next;
}}
// Load saved theme
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'light') {{
document.body.classList.add('light');
}}
// Auth
function getToken() {{ return localStorage.getItem('token') || ''; }}
async function api(url, opts = {{}}) {{
opts.headers = opts.headers || {{}};
opts.headers['Authorization'] = 'Bearer ' + getToken();
opts.headers['Content-Type'] = 'application/json';
const res = await fetch(url, opts);
if (res.status === 401) {{
localStorage.removeItem('token');
window.location.href = '/login';
return null;
}}
return res;
}}
// Status updates
async function fetchStatus() {{
const res = await api('/api/status');
if (!res) return;
currentStatus = await res.json();
updateUI();
}}
function updateUI() {{
// Status bar
const conn = currentStatus.connected;
document.getElementById('val-ipmi').textContent = conn ? 'Connected' : 'Disconnected';
document.getElementById('val-ipmi').className = 'value ' + (conn ? 'good' : 'danger');
const mode = currentStatus.enabled ? 'AUTO' : (currentStatus.manual_mode ? 'MANUAL' : 'BIOS');
document.getElementById('val-mode').textContent = mode;
const temps = currentStatus.temperatures || [];
const cpuTemps = temps.filter(t => t.location.includes('cpu'));
const maxTemp = cpuTemps.length > 0 ? Math.max(...cpuTemps.map(t => t.value)) : 0;
document.getElementById('val-temp').textContent = maxTemp.toFixed(1) + '°C';
document.getElementById('val-temp').className = 'value ' +
(maxTemp > 70 ? 'danger' : maxTemp > 50 ? 'medium' : 'low');
const avgSpeed = currentStatus.current_speeds?.all || 0;
document.getElementById('val-fans').textContent = avgSpeed + '%';
// Sync slider with current speed (only if not currently dragging)
const slider = document.getElementById('manual-speed');
if (slider && document.activeElement !== slider) {{
slider.value = avgSpeed;
document.getElementById('manual-val').textContent = avgSpeed + '%';
}}
document.getElementById('val-sensors').textContent = temps.length;
// Temperature display - grouped by category
const temps = currentStatus.temperatures || [];
// Group temperatures by location
const groups = {{
cpu1: [], cpu2: [], nvme: [], raid: [], ambient: [], exhaust: [], inlet: [], pcie: [], chipset: [], other: []
}};
temps.forEach(t => {{
const loc = t.location || 'other';
if (groups[loc]) groups[loc].push(t);
else groups.other.push(t);
}});
// Render CPU 1 Cores
const cpu1El = document.getElementById('temp-cpu1');
if (groups.cpu1.length > 0) {{
const cores = groups.cpu1.filter(t => t.name.toLowerCase().includes('core'));
const others = groups.cpu1.filter(t => !t.name.toLowerCase().includes('core'));
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">🖥️ CPU 1</h4><div class="temp-grid">';
cores.forEach((t, i) => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
html += `<div class="temp-item"><span>Core ${{(i+1)}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
others.forEach(t => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
const label = t.name.toLowerCase().includes('package') ? 'Package' : t.name;
html += `<div class="temp-item"><span>${{label}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
cpu1El.innerHTML = html + '</div>';
}} else cpu1El.innerHTML = '';
// Render CPU 2 Cores
const cpu2El = document.getElementById('temp-cpu2');
if (groups.cpu2.length > 0) {{
const cores = groups.cpu2.filter(t => t.name.toLowerCase().includes('core'));
const others = groups.cpu2.filter(t => !t.name.toLowerCase().includes('core'));
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">🖥️ CPU 2</h4><div class="temp-grid">';
cores.forEach((t, i) => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
html += `<div class="temp-item"><span>Core ${{(i+1)}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
others.forEach(t => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
const label = t.name.toLowerCase().includes('package') ? 'Package' : t.name;
html += `<div class="temp-item"><span>${{label}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
cpu2El.innerHTML = html + '</div>';
}} else cpu2El.innerHTML = '';
// Render NVMe
const nvmeEl = document.getElementById('temp-nvme');
if (groups.nvme.length > 0) {{
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">💾 NVMe SSD</h4><div class="temp-grid">';
groups.nvme.forEach((t, i) => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
html += `<div class="temp-item"><span>NVMe ${{(i+1)}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
nvmeEl.innerHTML = html + '</div>';
}} else nvmeEl.innerHTML = '';
// Render RAID
const raidEl = document.getElementById('temp-raid');
if (groups.raid.length > 0) {{
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">🔒 RAID Controller</h4><div class="temp-grid">';
groups.raid.forEach((t, i) => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
html += `<div class="temp-item"><span>Controller ${{(i+1)}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
raidEl.innerHTML = html + '</div>';
}} else raidEl.innerHTML = '';
// Render Ambient
const ambientEl = document.getElementById('temp-ambient');
if (groups.ambient.length > 0 || groups.exhaust.length > 0 || groups.inlet.length > 0) {{
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">🌡️ Ambient / System</h4><div class="temp-grid">';
[...groups.ambient, ...groups.exhaust, ...groups.inlet].forEach(t => {{
const cls = t.value > 40 ? 'high' : 'low';
const label = t.location === 'exhaust' ? 'Exhaust' : t.location === 'inlet' ? 'Inlet' : 'Ambient';
html += `<div class="temp-item"><span>${{label}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
ambientEl.innerHTML = html + '</div>';
}} else ambientEl.innerHTML = '';
// Render Others
const otherEl = document.getElementById('temp-other');
const others = [...groups.pcie, ...groups.chipset, ...groups.other];
if (others.length > 0) {{
let html = '<h4 style="color:var(--accent-primary);margin-bottom:8px;font-size:0.9rem;">📊 Other Sensors</h4><div class="temp-grid">';
others.forEach(t => {{
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
html += `<div class="temp-item"><span>${{t.name}}</span><span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span></div>`;
}});
otherEl.innerHTML = html + '</div>';
}} else otherEl.innerHTML = '';
// Fan grid
const fans = currentStatus.fans || [];
const fanConfigs = currentStatus.config?.fans || {{}};
const fanGroups = currentStatus.config?.fan_groups || {{}};
// Update Fan Groups tab
const fanGroupsList = document.getElementById('fan-groups-list');
if (Object.keys(fanGroups).length === 0) {{
fanGroupsList.innerHTML = '<p style="color:var(--text-secondary);">No groups defined. Groups let you control multiple fans together.</p>' +
'<button class="secondary" onclick="showGroupModal()">+ Create First Group</button>';
}} else {{
fanGroupsList.innerHTML = Object.entries(fanGroups).map(([name, group]) => {{
const fanCount = group.fan_ids?.length || 0;
return `<div class="fan-card" style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="fan-name">${{name}}</div>
<div class="fan-info">${{fanCount}} fan${{fanCount !== 1 ? 's' : ''}}</div>
</div>
<div style="display:flex;gap:5px;">
<button class="secondary small" onclick="showFanSpeedModal('${{name}}', true)">Set Speed</button>
<button class="secondary small" onclick="showGroupModal('${{name}}')">Edit</button>
<button class="danger small" onclick="deleteGroup('${{name}}')">Delete</button>
</div>
</div>
</div>`;
}}).join('') + '<button class="secondary" onclick="showGroupModal()" style="margin-top:10px;">+ Add Group</button>';
}}
// Update Individual Fan Config
const fanConfigList = document.getElementById('fan-config-list');
if (fans.length === 0) {{
fanConfigList.innerHTML = '<p style="color:var(--text-secondary);">Connect to IPMI to see fans</p>';
}} else {{
fanConfigList.innerHTML = fans.map(f => {{
const cfg = fanConfigs[f.fan_id] || {{}};
const name = cfg.name || `Fan ${{f.fan_number}}`;
const group = Object.entries(fanGroups).find(([n, g]) => g.fan_ids?.includes(f.fan_id));
const groupName = group ? group[0] : 'No group';
return `<div class="fan-card" style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="fan-name">${{name}}</div>
<div class="fan-info">${{f.speed_rpm || 0}} RPM • ${{f.speed_percent || 0}}% • Group: ${{groupName}}</div>
</div>
<div style="display:flex;gap:5px;">
<button class="secondary small" onclick="showFanSpeedModal('${{f.fan_id}}', false)">Set Speed</button>
<button class="secondary small" onclick="identifyFan('${{f.fan_id}}')">Identify</button>
</div>
</div>
</div>`;
}}).join('');
}}
const fanGrid = document.getElementById('fan-grid');
if (fans.length > 0) {{
fanGrid.innerHTML = fans.map(f => {{
const cfg = fanConfigs[f.fan_id] || {{}};
const name = cfg.name || `Fan ${{f.fan_number}}`;
return `<div class="fan-card">
<div class="fan-name">${{name}}</div>
<div class="fan-info">${{f.speed_rpm || 0}} RPM</div>
<div class="fan-info">ID: ${{f.fan_id}}</div>
</div>`;
}}).join('');
}}
// Update config fields
if (currentStatus.config) {{
currentConfig = currentStatus.config;
updateConfigFields();
updateCurvesList();
}}
}}
function updateConfigFields() {{
const cfg = currentConfig;
// IPMI
if (cfg.ipmi_host) document.getElementById('cfg-ipmi-host').value = cfg.ipmi_host;
if (cfg.ipmi_port) document.getElementById('cfg-ipmi-port').value = cfg.ipmi_port;
if (cfg.ipmi_username) document.getElementById('cfg-ipmi-user').value = cfg.ipmi_username;
// HTTP
document.getElementById('cfg-http-enabled').checked = cfg.http_sensor_enabled || false;
if (cfg.http_sensor_url) document.getElementById('cfg-http-url').value = cfg.http_sensor_url;
if (cfg.http_sensor_timeout) document.getElementById('cfg-http-timeout').value = cfg.http_sensor_timeout;
// Control
if (cfg.poll_interval) document.getElementById('cfg-poll').value = cfg.poll_interval;
if (cfg.primary_sensor) document.getElementById('cfg-sensor').value = cfg.primary_sensor;
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.panic_speed) document.getElementById('cfg-panic-speed').value = cfg.panic_speed;
document.getElementById('cfg-panic-no-data').checked = cfg.panic_on_no_data !== false;
}}
function updateCurvesList() {{
const cfg = currentConfig;
const curves = cfg.fan_curves || {{}};
const activeCurve = cfg.active_curve || 'Balanced';
const container = document.getElementById('curves-list');
if (Object.keys(curves).length === 0) {{
container.innerHTML = '<p style="color:var(--text-secondary);">No curves defined</p>';
return;
}}
container.innerHTML = Object.entries(curves).map(([name, curve]) => {{
const isActive = name === activeCurve;
const points = curve.points || [];
const pointsStr = points.map(p => `${{p.temp}}°C→${{p.speed}}%`).join(', ');
const group = curve.group ? `(Group: ${{curve.group}})` : '';
return `<div class="fan-card" style="margin-bottom:10px;${{isActive ? 'border:2px solid var(--accent-success);' : ''}}">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="fan-name">${{name}} ${{isActive ? '<span style="color:var(--accent-success);">(Active)</span>' : ''}} ${{group}}</div>
<div class="fan-info" style="font-size:0.8rem;margin-top:5px;">${{pointsStr}}</div>
</div>
<div style="display:flex;gap:5px;">
${{!isActive ? `<button class="secondary small" onclick="setActiveCurve('${{name}}')">Activate</button>` : ''}}
<button class="secondary small" onclick="showCurveModal('${{name}}')">Edit</button>
<button class="danger small" onclick="deleteCurve('${{name}}')">Delete</button>
</div>
</div>
</div>`;
}}).join('');
}}
async function setActiveCurve(name) {{
const res = await api('/api/curves/active', {{
method: 'POST',
body: JSON.stringify({{name}})
}});
if (!res) return;
const data = await res.json();
log(data.success ? `Curve "${{name}}" activated` : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}}
// Tabs
function showTab(tab) {{
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[onclick="showTab('${{tab}}')"]`).classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
}}
// Actions
async function testConnection() {{
log('Testing IPMI...');
const res = await api('/api/test', {{method: 'POST'}});
if (!res) return;
const data = await res.json();
log(data.success ? 'IPMI Connected' : 'IPMI Failed: ' + data.error, data.success ? 'success' : 'error');
}}
async function setAuto(enabled) {{
const res = await api('/api/control/auto', {{
method: 'POST',
body: JSON.stringify({{enabled}})
}});
if (!res) return;
const data = await res.json();
log(data.success ? (enabled ? 'Auto ON' : 'Auto OFF') : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}}
async function setManualSpeed() {{
const speed = parseInt(document.getElementById('manual-speed').value);
const res = await api('/api/control/manual', {{
method: 'POST',
body: JSON.stringify({{speed, fan_id: '0xff'}})
}});
if (!res) return;
const data = await res.json();
log(data.success ? `Manual: ${{speed}}%` : 'Failed: ' + data.error, data.success ? 'success' : 'error');
fetchStatus();
}}
// Identify fan
function showIdentifyModal() {{
const fans = currentStatus.fans || [];
const fanConfigs = currentStatus.config?.fans || {{}};
const list = document.getElementById('identify-fan-list');
if (fans.length === 0) {{
list.innerHTML = '<p>No fans detected</p>';
}} else {{
list.innerHTML = fans.map(f => {{
const cfg = fanConfigs[f.fan_id] || {{}};
const name = cfg.name || `Fan ${{f.fan_number}}`;
return `<button class="secondary" style="width:100%;margin-bottom:8px;" onclick="identifyFan('${{f.fan_id}}')">${{name}} (${{f.fan_id}})</button>`;
}}).join('');
}}
document.getElementById('identify-modal').classList.add('active');
}}
async function identifyFan(fanId) {{
const res = await api('/api/fans/identify', {{
method: 'POST',
body: JSON.stringify({{fan_id: fanId}})
}});
if (!res) return;
const data = await res.json();
log(data.success ? `Identifying fan ${{fanId}}` : 'Failed: ' + data.error, data.success ? 'success' : 'error');
}}
async function stopIdentify() {{
const res = await api('/api/fans/stop-identify', {{method: 'POST'}});
log('Identify mode stopped');
hideModal('identify-modal');
fetchStatus();
}}
// Fan Group Management
let currentFanSpeedTarget = null;
function showGroupModal(groupName = null) {{
const fans = currentStatus.fans || [];
const container = document.getElementById('group-fan-selection');
if (fans.length === 0) {{
container.innerHTML = '<p style="color:var(--text-secondary);">No fans available. Connect to IPMI first.</p>';
}} else {{
const fanConfigs = currentStatus.config?.fans || {{}};
container.innerHTML = fans.map(f => {{
const cfg = fanConfigs[f.fan_id] || {{}};
const name = cfg.name || `Fan ${{f.fan_number}}`;
return `<label style="display:block;margin:5px 0;cursor:pointer;">
<input type="checkbox" value="${{f.fan_id}}" class="group-fan-checkbox"> ${{name}} (${{f.fan_id}})
</label>`;
}}).join('');
}}
document.getElementById('group-name').value = groupName || '';
showModal('group-modal');
}}
async function saveGroup() {{
const name = document.getElementById('group-name').value.trim();
if (!name) {{
log('Group name required', 'error');
return;
}}
const fanIds = Array.from(document.querySelectorAll('.group-fan-checkbox:checked')).map(cb => cb.value);
if (fanIds.length === 0) {{
log('Select at least one fan', 'error');
return;
}}
const res = await api('/api/fans/groups', {{
method: 'POST',
body: JSON.stringify({{name, fan_ids: fanIds}})
}});
const data = await res.json();
if (data.success) {{
log(`Group "${{name}}" saved`, 'success');
hideModal('group-modal');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
function showFanSpeedModal(target, isGroup = false) {{
currentFanSpeedTarget = {{target, isGroup}};
document.getElementById('fan-speed-target').textContent = `Setting speed for: ${{target}}`;
document.getElementById('fan-speed-slider').value = 50;
document.getElementById('fan-speed-val').textContent = '50%';
showModal('fan-speed-modal');
}}
async function applyFanSpeed() {{
const speed = parseInt(document.getElementById('fan-speed-slider').value);
const {{target, isGroup}} = currentFanSpeedTarget;
const res = await api('/api/control/manual', {{
method: 'POST',
body: JSON.stringify({{speed, fan_id: isGroup ? null : target, group: isGroup ? target : null}})
}});
const data = await res.json();
if (data.success) {{
log(`${{isGroup ? 'Group' : 'Fan'}} "${{target}}" set to ${{speed}}%`, 'success');
hideModal('fan-speed-modal');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
async function setAllFans(speed) {{
const res = await api('/api/control/manual', {{
method: 'POST',
body: JSON.stringify({{speed, fan_id: '0xff'}})
}});
const data = await res.json();
if (data.success) {{
log(`All fans set to ${{speed}}%`, 'success');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
async function deleteGroup(name) {{
if (!confirm(`Delete group "${{name}}"?`)) return;
const res = await api('/api/fans/groups', {{
method: 'DELETE',
body: JSON.stringify({{name}})
}});
const data = await res.json();
if (data.success) {{
log(`Group "${{name}}" deleted`, 'success');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
// Curve Management
function showCurveModal(curveName = null) {{
// Populate group dropdown
const groups = currentStatus.config?.fan_groups || {{}};
const select = document.getElementById('curve-group');
select.innerHTML = '<option value="">All Fans</option>' +
Object.keys(groups).map(name => `<option value="${{name}}">${{name}}</option>`).join('');
if (curveName && currentStatus.config?.fan_curves?.[curveName]) {{
const curve = currentStatus.config.fan_curves[curveName];
document.getElementById('curve-name').value = curveName;
document.getElementById('curve-group').value = curve.group || '';
// Populate points
const container = document.getElementById('curve-points');
const points = curve.points || [];
container.innerHTML = points.map(p => `
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="${{p.temp}}">
<input type="number" class="curve-speed" placeholder="Speed %" value="${{p.speed}}">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
`).join('');
}} else {{
// Default new curve
document.getElementById('curve-name').value = '';
document.getElementById('curve-group').value = '';
document.getElementById('curve-points').innerHTML = `
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="30">
<input type="number" class="curve-speed" placeholder="Speed %" value="20">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="50">
<input type="number" class="curve-speed" placeholder="Speed %" value="50">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
<div class="curve-point" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;">
<input type="number" class="curve-temp" placeholder="Temp °C" value="70">
<input type="number" class="curve-speed" placeholder="Speed %" value="100">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
</div>
`;
}}
showModal('curve-modal');
}}
function addCurvePoint() {{
const container = document.getElementById('curve-points');
const div = document.createElement('div');
div.className = 'curve-point';
div.style.cssText = 'display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:8px;';
div.innerHTML = `
<input type="number" class="curve-temp" placeholder="Temp °C" value="">
<input type="number" class="curve-speed" placeholder="Speed %" value="">
<button class="danger small" onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(div);
}}
async function saveCurve() {{
const name = document.getElementById('curve-name').value.trim();
if (!name) {{
log('Curve name required', 'error');
return;
}}
const group = document.getElementById('curve-group').value;
// Collect points
const points = [];
document.querySelectorAll('.curve-point').forEach(el => {{
const temp = parseFloat(el.querySelector('.curve-temp').value);
const speed = parseFloat(el.querySelector('.curve-speed').value);
if (!isNaN(temp) && !isNaN(speed)) {{
points.push({{temp, speed}});
}}
}});
if (points.length < 2) {{
log('At least 2 points required', 'error');
return;
}}
// Sort by temperature
points.sort((a, b) => a.temp - b.temp);
const res = await api('/api/curves', {{
method: 'POST',
body: JSON.stringify({{name, points, group}})
}});
const data = await res.json();
if (data.success) {{
log(`Curve "${{name}}" saved`, 'success');
hideModal('curve-modal');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
async function deleteCurve(name) {{
if (!confirm(`Delete curve "${{name}}"?`)) return;
const res = await api(`/api/curves/${{name}}`, {{method: 'DELETE'}});
const data = await res.json();
if (data.success) {{
log(`Curve "${{name}}" deleted`, 'success');
fetchStatus();
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
// Save functions
async function saveIPMI() {{
const data = {{
host: document.getElementById('cfg-ipmi-host').value,
username: document.getElementById('cfg-ipmi-user').value,
password: document.getElementById('cfg-ipmi-pass').value || undefined,
port: parseInt(document.getElementById('cfg-ipmi-port').value) || 623
}};
const res = await api('/api/config/ipmi', {{method: 'POST', body: JSON.stringify(data)}});
const result = await res.json();
log(result.success ? 'IPMI saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
if (result.success) document.getElementById('cfg-ipmi-pass').value = '';
}}
async function saveHTTP() {{
const data = {{
enabled: document.getElementById('cfg-http-enabled').checked,
url: document.getElementById('cfg-http-url').value,
timeout: parseInt(document.getElementById('cfg-http-timeout').value) || 10
}};
const res = await api('/api/config/http', {{method: 'POST', body: JSON.stringify(data)}});
const result = await res.json();
log(result.success ? 'HTTP sensor saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
}}
async function saveControl() {{
const data = {{
poll_interval: parseInt(document.getElementById('cfg-poll').value),
primary_sensor: document.getElementById('cfg-sensor').value,
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),
panic_speed: parseInt(document.getElementById('cfg-panic-speed').value),
panic_on_no_data: document.getElementById('cfg-panic-no-data').checked
}};
const res = await api('/api/config/settings', {{method: 'POST', body: JSON.stringify(data)}});
const result = await res.json();
log(result.success ? 'Settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
}}
// Modal helpers
function showModal(id) {{ document.getElementById(id).classList.add('active'); }}
function hideModal(id) {{ document.getElementById(id).classList.remove('active'); }}
function showPasswordModal() {{ showModal('password-modal'); }}
async function changePassword() {{
const current = document.getElementById('pwd-current').value;
const newPass = document.getElementById('pwd-new').value;
const confirm = document.getElementById('pwd-confirm').value;
if (newPass !== confirm) {{
log('Passwords do not match', 'error');
return;
}}
const res = await api('/api/auth/change-password', {{
method: 'POST',
body: JSON.stringify({{current_password: current, new_password: newPass}})
}});
const data = await res.json();
if (data.success) {{
log('Password changed', 'success');
hideModal('password-modal');
}} else {{
log('Failed: ' + data.error, 'error');
}}
}}
function logout() {{
localStorage.removeItem('token');
window.location.href = '/login';
}}
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 = ''; }}
// Init
setInterval(fetchStatus, 3000);
fetchStatus();
</script>
<footer style="position:fixed;bottom:0;left:0;right:0;text-align:center;padding:15px;color:var(--text-secondary);font-size:0.85rem;border-top:1px solid var(--border);background:var(--bg-card);z-index:100;">
<div style="margin-bottom:8px;">
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller/issues" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">🐛 Report Bug</a>
<a href="https://github.com/ImpulsiveFPS/IPMI-Controller" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">📁 GitHub Repo</a>
<a href="https://ko-fi.com/impulsivefps" target="_blank" style="color:var(--accent-primary);text-decoration:none;margin:0 10px;">☕ Support on Ko-fi</a>
</div>
<div>IPMI Controller v1.0.0 - Built by ImpulsiveFPS</div>
</footer>
<style>body{{padding-bottom:80px !important;}}</style>
</body>
</html>'''
LOGIN_HTML = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.ico">
<title>Login - IPMI Controller</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, #0f0f1e 0%, #1a1a2e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.login-box {
background: rgba(255,255,255,0.05);
padding: 40px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.1);
width: 100%;
max-width: 400px;
}
h1 { text-align: center; margin-bottom: 30px; font-size: 1.5rem; }
.icon-logo { width: 32px; height: 32px; display: inline-block; vertical-align: middle; margin-right: 8px; filter: brightness(0) invert(1); }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #aaa; font-size: 0.9rem; }
input {
width: 100%;
padding: 12px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 1rem;
}
input:focus { outline: none; border-color: #2196f3; }
button {
width: 100%;
padding: 14px;
background: #2196f3;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
button:hover { background: #1976d2; }
.error {
background: rgba(244,67,54,0.2);
border: 1px solid #f44336;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div class="login-box">
<h1><img src="/icons/favicon.svg" class="icon-logo" alt=""> IPMI Controller</h1>
<div class="error" id="error"></div>
<form id="login-form">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" required autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('error');
errorEl.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
const data = await res.json();
if (data.success && data.token) {
localStorage.setItem('token', data.token);
window.location.href = '/';
} else {
errorEl.textContent = data.error || 'Login failed';
errorEl.style.display = 'block';
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.style.display = 'block';
}
});
</script>
</body>
</html>'''
with open(Path(__file__).parent / 'setup_html.txt', 'r') as f:
SETUP_HTML = f.read().strip().replace("SETUP_HTML = '''", "").replace("'''", "")
# FastAPI app
@asynccontextmanager
async def lifespan(app: FastAPI):
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Auto-start fan control if enabled in config and setup is complete
if user_manager.is_setup_complete():
try:
service = get_service(str(CONFIG_FILE))
if service.config.get('enabled', False):
logger.info("Auto-starting fan control (enabled in config)")
if not service.running:
service.start()
except Exception as e:
logger.error(f"Auto-start failed: {e}")
yield
app = FastAPI(
title="IPMI Controller",
description="Advanced fan control for Dell servers",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for icons
app.mount("/icons", StaticFiles(directory=str(Path(__file__).parent / "static" / "icons")), name="icons")
# Routes
@app.get("/favicon.ico")
async def favicon():
"""Fan icon as favicon."""
icon_path = Path(__file__).parent / "static" / "icons" / "favicon.svg"
return Response(content=icon_path.read_text(), media_type="image/svg+xml")
@app.get("/")
async def root(request: Request):
"""Main dashboard."""
if not user_manager.is_setup_complete():
return HTMLResponse(content=SETUP_HTML)
# Get theme preference from query or default to dark
theme = request.query_params.get('theme', 'dark')
return HTMLResponse(content=get_html(theme))
@app.get("/login")
async def login_page():
"""Login page."""
if not user_manager.is_setup_complete():
return HTMLResponse(content=SETUP_HTML)
return HTMLResponse(content=LOGIN_HTML)
# Auth API
@app.post("/api/auth/login")
async def api_login(credentials: UserLogin):
if not user_manager.verify(credentials.username, credentials.password):
return {"success": False, "error": "Invalid credentials"}
token = user_manager.create_token(credentials.username)
return {"success": True, "token": token}
@app.post("/api/auth/change-password")
async def api_change_password(data: ChangePassword, username: str = Depends(get_current_user)):
if not user_manager.change_password(username, data.current_password, data.new_password):
return {"success": False, "error": "Current password is incorrect"}
return {"success": True}
# Setup test endpoints (no auth required for setup)
@app.post("/api/setup/test-ipmi")
async def api_setup_test_ipmi(data: dict):
"""Test IPMI connection during setup."""
from fan_controller import IPMIFanController
try:
controller = IPMIFanController(
host=data.get('host'),
username=data.get('username'),
password=data.get('password'),
port=data.get('port', 623)
)
if controller.test_connection():
return {"success": True}
return {"success": False, "error": "Connection failed"}
except Exception as e:
return {"success": False, "error": str(e)}
@app.post("/api/setup/test-http")
async def api_setup_test_http(data: dict):
"""Test HTTP sensor during setup."""
from fan_controller import HTTPSensorClient
try:
client = HTTPSensorClient(url=data.get('url'), timeout=10)
temps = client.fetch_sensors()
return {"success": True, "sensors": len(temps)}
except Exception as e:
return {"success": False, "error": str(e)}
@app.post("/api/setup")
async def api_setup(data: SetupRequest):
if user_manager.is_setup_complete():
return {"success": False, "error": "Setup already completed"}
if not user_manager.create(data.admin_username, data.admin_password):
return {"success": False, "error": "Failed to create user"}
service = get_service(str(CONFIG_FILE))
updates = {
"ipmi_host": data.ipmi_host,
"ipmi_username": data.ipmi_username,
"ipmi_password": data.ipmi_password,
"ipmi_port": data.ipmi_port,
"enabled": data.enabled or False,
"active_curve": data.active_curve or "Balanced"
}
# Add HTTP config if provided
if data.http_sensor_enabled:
updates["http_sensor_enabled"] = True
updates["http_sensor_url"] = data.http_sensor_url or ''
updates["http_sensor_timeout"] = data.http_sensor_timeout or 10
service.update_config(**updates)
# Initialize controller and start polling if auto is enabled
if data.enabled:
logger.info("Setup complete - auto fan control enabled, starting service...")
if service._init_controller():
service.start()
logger.info("Fan control service started successfully")
else:
logger.warning("Could not connect to IPMI - service not started")
token = user_manager.create_token(data.admin_username)
return {"success": True, "token": token}
# Status API
@app.get("/api/status")
async def api_status(username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
return service.get_status()
@app.post("/api/test")
async def api_test(username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Failed to connect - check config"}
success = service.controller.test_connection()
return {"success": success, "error": None if success else "Connection failed"}
# Control API
@app.post("/api/control/auto")
async def api_control_auto(data: dict, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
service.set_auto_mode(data.get('enabled', False))
if data.get('enabled') and not service.running:
if not service.start():
return {"success": False, "error": "Failed to start"}
return {"success": True}
@app.post("/api/control/manual")
async def api_control_manual(req: ManualSpeedRequest, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
if not service.controller:
if not service._init_controller():
return {"success": False, "error": "Not connected"}
# Handle group speed setting
if req.group:
groups = service.config.get('fan_groups', {})
if req.group not in groups:
return {"success": False, "error": f"Group '{req.group}' not found"}
fan_ids = groups[req.group].get('fan_ids', [])
success = True
for fan_id in fan_ids:
if not service.set_manual_speed(req.speed, fan_id):
success = False
return {"success": success}
# Handle individual fan or all fans
if service.set_manual_speed(req.speed, req.fan_id):
return {"success": True}
return {"success": False, "error": "Failed"}
# Config API
@app.post("/api/config/ipmi")
async def api_config_ipmi(data: IPMIConfig, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
updates = {
"ipmi_host": data.host,
"ipmi_username": data.username,
"ipmi_port": data.port
}
if data.password:
updates["ipmi_password"] = data.password
service.update_config(**updates)
return {"success": True}
@app.post("/api/config/http")
async def api_config_http(data: HTTPConfig, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
service.update_config(
http_sensor_enabled=data.enabled,
http_sensor_url=data.url,
http_sensor_timeout=data.timeout
)
return {"success": True}
@app.post("/api/config/settings")
async def api_config_settings(data: FanSettings, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
updates = {k: v for k, v in data.model_dump().items() if v is not None}
service.update_config(**updates)
return {"success": True}
# Fan Curves API
@app.get("/api/curves")
async def api_list_curves(username: str = Depends(get_current_user)):
"""List all fan curves."""
service = get_service(str(CONFIG_FILE))
return {
"curves": service.config.get('fan_curves', {}),
"active": service.config.get('active_curve', 'Balanced')
}
@app.post("/api/curves")
async def api_create_curve(data: dict, username: str = Depends(get_current_user)):
"""Create or update a fan curve."""
service = get_service(str(CONFIG_FILE))
name = data.get('name')
if not name:
return {"success": False, "error": "Name required"}
curves = service.config.get('fan_curves', {})
curves[name] = {
"points": data.get('points', []),
"sensor_source": data.get('sensor_source', 'cpu'),
"applies_to": data.get('applies_to', 'all'),
"group": data.get('group') or None
}
service.update_config(fan_curves=curves)
return {"success": True}
@app.post("/api/curves/active")
async def api_set_active_curve(data: dict, username: str = Depends(get_current_user)):
"""Set active fan curve."""
service = get_service(str(CONFIG_FILE))
name = data.get('name')
curves = service.config.get('fan_curves', {})
if name not in curves:
return {"success": False, "error": "Curve not found"}
service.update_config(active_curve=name)
return {"success": True}
@app.delete("/api/curves/{name}")
async def api_delete_curve(name: str, username: str = Depends(get_current_user)):
"""Delete a fan curve."""
service = get_service(str(CONFIG_FILE))
curves = service.config.get('fan_curves', {})
if name in curves:
del curves[name]
service.update_config(fan_curves=curves)
return {"success": True}
# Fan API
@app.post("/api/fans/identify")
async def api_identify_fan(req: IdentifyRequest, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
if service.identify_fan(req.fan_id):
return {"success": True}
return {"success": False, "error": "Failed"}
@app.post("/api/fans/stop-identify")
async def api_stop_identify(username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
service.stop_identify()
return {"success": True}
# Fan Groups API
@app.post("/api/fans/groups")
async def api_save_group(data: dict, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
name = data.get('name')
fan_ids = data.get('fan_ids', [])
if not name:
return {"success": False, "error": "Group name required"}
if 'fan_groups' not in service.config:
service.config['fan_groups'] = {}
service.config['fan_groups'][name] = {'fan_ids': fan_ids}
service._save_config()
return {"success": True}
@app.delete("/api/fans/groups")
async def api_delete_group(data: dict, username: str = Depends(get_current_user)):
service = get_service(str(CONFIG_FILE))
name = data.get('name')
if 'fan_groups' in service.config and name in service.config['fan_groups']:
del service.config['fan_groups'][name]
service._save_config()
return {"success": True}
return {"success": False, "error": "Group not found"}
# Public API (no auth required - for external integrations)
@app.get("/api/public/status")
async def api_public_status():
"""Public status endpoint for integrations."""
service = get_service(str(CONFIG_FILE))
status = service.get_status()
# Return limited public data
return {
"temperatures": status.get("temperatures", []),
"fans": status.get("fans", []),
"current_speeds": status.get("current_speeds", {}),
"connected": status.get("connected", False),
"enabled": status.get("enabled", False)
}
@app.get("/api/public/temperatures")
async def api_public_temps():
"""Public temperatures endpoint."""
service = get_service(str(CONFIG_FILE))
return {"temperatures": service.get_status().get("temperatures", [])}
@app.get("/api/public/fans")
async def api_public_fans():
"""Public fans endpoint."""
service = get_service(str(CONFIG_FILE))
return {"fans": service.get_status().get("fans", [])}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)