1891 lines
80 KiB
Python
1891 lines
80 KiB
Python
"""
|
||
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 class="temp-grid" id="temp-grid">
|
||
<div class="temp-item"><span>Loading...</span></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 grid
|
||
const tempGrid = document.getElementById('temp-grid');
|
||
if (temps.length > 0) {{
|
||
tempGrid.innerHTML = temps.map(t => {{
|
||
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'medium' : 'low';
|
||
return `<div class="temp-item">
|
||
<span>${{t.name}}</span>
|
||
<span class="temp-value ${{cls}}">${{t.value.toFixed(1)}}°C</span>
|
||
</div>`;
|
||
}}).join('');
|
||
}}
|
||
|
||
// 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('/home/devmatrix/.openclaw/workspace/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)
|