1447 lines
58 KiB
Python
1447 lines
58 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 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
|
|
|
|
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: str = "0xff"
|
|
|
|
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;
|
|
}
|
|
"""
|
|
}
|
|
|
|
# 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 svg {{ width: 100%; height: 100%; }}
|
|
.icon-svg {{ width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-right: 6px; }}
|
|
.icon-svg svg {{ width: 100%; height: 100%; fill: currentColor; }}
|
|
.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><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2v.35a7 7 0 015.65 6.65 1 1 0 01-2 0 5 5 0 00-10 0 1 1 0 01-2 0A7 7 0 019.35 4.35 2 2 0 0112 2zm0 6a4 4 0 00-4 4v7h8v-7a4 4 0 00-4-4z"/></svg></span>IPMI Controller</h1>
|
|
<div class="header-actions">
|
|
<button class="secondary small" onclick="toggleTheme()"><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 4a1 1 0 011 1v2a1 1 0 11-2 0V5a1 1 0 011-1zm0 14a5 5 0 100-10 5 5 0 000 10zm0-2a3 3 0 110-6 3 3 0 010 6zM4.93 4.93a1 1 0 011.41 0l1.42 1.42a1 1 0 11-1.41 1.41L4.93 6.34a1 1 0 010-1.41zm0 14.14a1 1 0 010-1.41l1.42-1.42a1 1 0 111.41 1.41l-1.42 1.42a1 1 0 01-1.41 0zM19.07 4.93a1 1 0 010 1.41l-1.42 1.42a1 1 0 11-1.41-1.41l1.42-1.42a1 1 0 011.41 0zM12 18a1 1 0 011 1v2a1 1 0 11-2 0v-2a1 1 0 011-1z"/></svg></span>Theme</button>
|
|
<button class="secondary small" onclick="showPasswordModal()"><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 00-5 5v2H6a2 2 0 00-2 2v9a2 2 0 002 2h12a2 2 0 002-2v-9a2 2 0 00-2-2h-1V7a5 5 0 00-5-5zm-3 5a3 3 0 116 0v2H9V7zm3 7a1 1 0 01-1 1v2a1 1 0 112 0v-2a1 1 0 01-1-1z"/></svg></span>Password</button>
|
|
<button class="secondary small" onclick="logout()"><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M10 3H6a2 2 0 00-2 2v14a2 2 0 002 2h4M17 16l4-4m0 0l-4-4m4 4H10"/></svg></span>Logout</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-bar">
|
|
<div class="status-item" id="status-ipmi">
|
|
<div class="icon"><svg viewBox="0 0 24 24"><path d="M4 4h16v12H4V4zm2 2v8h12V6H6zm-2 12h16v2H4v-2zm4-10h2v6H8V8zm4 0h2v6h-2V8zm4 0h2v6h-2V8z"/></svg></div>
|
|
<div class="label">IPMI</div>
|
|
<div class="value" id="val-ipmi">-</div>
|
|
</div>
|
|
<div class="status-item" id="status-mode">
|
|
<div class="icon"><svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm1-8h4v2h-6V7h2v5z"/></svg></div>
|
|
<div class="label">Mode</div>
|
|
<div class="value" id="val-mode">-</div>
|
|
</div>
|
|
<div class="status-item" id="status-temp">
|
|
<div class="icon"><svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2v.35a7 7 0 015.65 6.65 1 1 0 01-2 0 5 5 0 00-10 0 1 1 0 01-2 0A7 7 0 019.35 4.35 2 2 0 0112 2zm0 6a4 4 0 00-4 4v7h8v-7a4 4 0 00-4-4z"/></svg></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"><svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 00-5 5v3H5a2 2 0 00-2 2v8a2 2 0 002 2h14a2 2 0 002-2v-8a2 2 0 00-2-2h-2V7a5 5 0 00-5-5zm-3 5a3 3 0 116 0v3H9V7zm6 7a1 1 0 01-1 1 1 1 0 01-1-1 1 1 0 011-1 1 1 0 011 1z"/></svg></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"><svg viewBox="0 0 24 24"><path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h16v2H4v-2z"/></svg></div>
|
|
<div class="label">Sensors</div>
|
|
<div class="value" id="val-sensors">-</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Quick Controls -->
|
|
<div class="card">
|
|
<h2><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm1-8h4v2h-6V7h2v5z"/></svg></span>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><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2v.35a7 7 0 015.65 6.65 1 1 0 01-2 0 5 5 0 00-10 0 1 1 0 01-2 0A7 7 0 019.35 4.35 2 2 0 0112 2zm0 6a4 4 0 00-4 4v7h8v-7a4 4 0 00-4-4z"/></svg></span>Temperatures</h2>
|
|
<div class="temp-grid" id="temp-grid">
|
|
<div class="temp-item"><span>Loading...</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fans -->
|
|
<div class="card">
|
|
<h2><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 00-5 5v3H5a2 2 0 00-2 2v8a2 2 0 002 2h14a2 2 0 002-2v-8a2 2 0 00-2-2h-2V7a5 5 0 00-5-5zm-3 5a3 3 0 116 0v3H9V7z"/></svg></span>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"><path d="M4 4h16v12H4V4zm2 2v8h12V6H6zm-2 12h16v2H4v-2z"/></svg></span>IPMI</button>
|
|
<button class="tab" onclick="showTab('http')">🌐 HTTP</button>
|
|
<button class="tab" onclick="showTab('control')">⚙️ Control</button>
|
|
<button class="tab" onclick="showTab('fans')"><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 00-5 5v3H5a2 2 0 00-2 2v8a2 2 0 002 2h14a2 2 0 002-2v-8a2 2 0 00-2-2h-2V7a5 5 0 00-5-5zm-3 5a3 3 0 116 0v3H9V7z"/></svg></span>Fans</button>
|
|
<button class="tab" onclick="showTab('curves')">📈 Curves</button>
|
|
<button class="tab" onclick="showTab('logs')">📝 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 Configuration</h3>
|
|
<div id="fan-config-list">
|
|
<p style="color:var(--text-secondary);">Connect to IPMI to see fans</p>
|
|
</div>
|
|
<h4 style="margin-top:20px;">Fan Groups</h4>
|
|
<div id="fan-groups-list">
|
|
<p style="color:var(--text-secondary);">No groups defined</p>
|
|
</div>
|
|
<button class="secondary" onclick="showGroupModal()">+ Add Group</button>
|
|
</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>
|
|
|
|
<script>
|
|
let currentStatus = {{}};
|
|
let currentConfig = {{}};
|
|
|
|
// Theme
|
|
function toggleTheme() {{
|
|
const current = localStorage.getItem('theme') || 'dark';
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
localStorage.setItem('theme', next);
|
|
location.reload();
|
|
}}
|
|
|
|
// 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 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(', ');
|
|
|
|
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>' : ''}}</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>` : ''}}
|
|
</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');
|
|
}}
|
|
|
|
// 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>
|
|
</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; }
|
|
.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><span class="icon-svg"><svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2v.35a7 7 0 015.65 6.65 1 1 0 01-2 0 5 5 0 00-10 0 1 1 0 01-2 0A7 7 0 019.35 4.35 2 2 0 0112 2zm0 6a4 4 0 00-4 4v7h8v-7a4 4 0 00-4-4z"/></svg></span>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)
|
|
yield
|
|
|
|
app = FastAPI(
|
|
title="IPMI Controller",
|
|
description="Advanced fan control for Dell servers",
|
|
version="3.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Routes
|
|
@app.get("/favicon.ico")
|
|
async def favicon():
|
|
"""Transparent 1x1 PNG favicon."""
|
|
return Response(
|
|
content=bytes([
|
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
|
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54,
|
|
0x08, 0xD7, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00,
|
|
0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33,
|
|
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44,
|
|
0xAE, 0x42, 0x60, 0x82
|
|
]),
|
|
media_type="image/png"
|
|
)
|
|
|
|
@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
|
|
}
|
|
|
|
# Add HTTP config if provided
|
|
if hasattr(data, 'http_sensor_enabled') and data.http_sensor_enabled:
|
|
updates["http_sensor_enabled"] = True
|
|
updates["http_sensor_url"] = getattr(data, 'http_sensor_url', '')
|
|
updates["http_sensor_timeout"] = getattr(data, 'http_sensor_timeout', 10)
|
|
|
|
service.update_config(**updates)
|
|
|
|
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"}
|
|
|
|
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')
|
|
}
|
|
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}
|
|
|
|
# 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)
|