1415 lines
54 KiB
Python
1415 lines
54 KiB
Python
"""
|
||
Web API for IPMI Fan Controller v2 - With Auth & SSH Support
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import hashlib
|
||
import secrets
|
||
import re
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict
|
||
from datetime import datetime, timedelta
|
||
|
||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel, Field, validator
|
||
|
||
# Import the fan controller
|
||
import sys
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
from fan_controller import get_service, FanControlService, IPMIFanController
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Security
|
||
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"
|
||
SSH_KEYS_DIR = DATA_DIR / "ssh_keys"
|
||
SSH_KEYS_DIR.mkdir(exist_ok=True)
|
||
|
||
# Pydantic models
|
||
class UserLogin(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
class ChangePassword(BaseModel):
|
||
current_password: str
|
||
new_password: str
|
||
|
||
@validator('new_password')
|
||
def password_strength(cls, v):
|
||
if len(v) < 6:
|
||
raise ValueError('Password must be at least 6 characters')
|
||
return v
|
||
|
||
class IPMIConfig(BaseModel):
|
||
host: str
|
||
username: str
|
||
password: Optional[str] = None # Only required on initial setup
|
||
port: int = 623
|
||
|
||
class SSHConfig(BaseModel):
|
||
enabled: bool = False
|
||
host: Optional[str] = None
|
||
username: Optional[str] = None
|
||
password: Optional[str] = None
|
||
use_key: bool = False
|
||
key_filename: Optional[str] = None
|
||
|
||
class FanSettings(BaseModel):
|
||
enabled: Optional[bool] = None
|
||
interval: Optional[int] = Field(None, ge=5, le=300)
|
||
min_speed: Optional[int] = Field(None, ge=0, le=100)
|
||
max_speed: Optional[int] = Field(None, ge=0, le=100)
|
||
panic_temp: Optional[float] = Field(None, ge=50, le=100)
|
||
panic_speed: Optional[int] = Field(None, ge=0, le=100)
|
||
|
||
class FanCurvePoint(BaseModel):
|
||
temp: float = Field(..., ge=0, le=100)
|
||
speed: int = Field(..., ge=0, le=100)
|
||
|
||
class FanCurveUpdate(BaseModel):
|
||
points: List[FanCurvePoint]
|
||
|
||
class ManualSpeedRequest(BaseModel):
|
||
speed: int = Field(..., ge=0, le=100)
|
||
|
||
class SetupRequest(BaseModel):
|
||
admin_username: str = Field(..., min_length=3)
|
||
admin_password: str = Field(..., min_length=6)
|
||
ipmi_host: str
|
||
ipmi_username: str
|
||
ipmi_password: str
|
||
ipmi_port: int = 623
|
||
|
||
# User management
|
||
class UserManager:
|
||
def __init__(self):
|
||
self.users_file = USERS_FILE
|
||
self._users = {}
|
||
self._sessions = {} # token -> (username, expiry)
|
||
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}")
|
||
self._users = {}
|
||
|
||
def _save(self):
|
||
with open(self.users_file, 'w') as f:
|
||
json.dump({'users': self._users}, f)
|
||
|
||
def _hash_password(self, password: str) -> str:
|
||
return hashlib.sha256(password.encode()).hexdigest()
|
||
|
||
def verify_user(self, username: str, password: str) -> bool:
|
||
if username not in self._users:
|
||
return False
|
||
return self._users[username] == self._hash_password(password)
|
||
|
||
def create_user(self, username: str, password: str) -> bool:
|
||
if username in self._users:
|
||
return False
|
||
self._users[username] = self._hash_password(password)
|
||
self._save()
|
||
return True
|
||
|
||
def change_password(self, username: str, current: str, new: str) -> bool:
|
||
if not self.verify_user(username, current):
|
||
return False
|
||
self._users[username] = self._hash_password(new)
|
||
self._save()
|
||
return True
|
||
|
||
def create_token(self, username: str) -> str:
|
||
token = secrets.token_urlsafe(32)
|
||
expiry = datetime.utcnow() + timedelta(days=7)
|
||
self._sessions[token] = (username, expiry)
|
||
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()
|
||
|
||
# Auth dependency
|
||
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
|
||
|
||
# HTML Templates
|
||
LOGIN_HTML = '''<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Login - IPMI Fan 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, #1a1a2e 0%, #16213e 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;
|
||
transition: background 0.2s;
|
||
}
|
||
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>🌬️ Fan 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) {
|
||
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>'''
|
||
|
||
SETUP_HTML = '''<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Setup - IPMI Fan 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, #1a1a2e 0%, #16213e 100%);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
padding: 20px;
|
||
}
|
||
.setup-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: 500px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
}
|
||
h1 { text-align: center; margin-bottom: 10px; font-size: 1.5rem; }
|
||
.subtitle { text-align: center; color: #888; margin-bottom: 30px; }
|
||
.section { margin-bottom: 25px; padding-bottom: 25px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||
.section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||
h2 { font-size: 1.1rem; color: #64b5f6; margin-bottom: 15px; }
|
||
.form-group { margin-bottom: 15px; }
|
||
label { display: block; margin-bottom: 6px; color: #aaa; font-size: 0.9rem; }
|
||
input, select {
|
||
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, select:focus {
|
||
outline: none;
|
||
border-color: #2196f3;
|
||
}
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 15px;
|
||
}
|
||
button {
|
||
width: 100%;
|
||
padding: 14px;
|
||
background: #4caf50;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
button:hover { background: #388e3c; }
|
||
.error {
|
||
background: rgba(244,67,54,0.2);
|
||
border: 1px solid #f44336;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
display: none;
|
||
}
|
||
.checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.checkbox input {
|
||
width: auto;
|
||
}
|
||
#ssh-fields { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="setup-box">
|
||
<h1>🌬️ Fan Controller Setup</h1>
|
||
<p class="subtitle">Configure your server connection</p>
|
||
|
||
<div class="error" id="error"></div>
|
||
|
||
<form id="setup-form">
|
||
<div class="section">
|
||
<h2>👤 Admin Account</h2>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Username</label>
|
||
<input type="text" id="admin-user" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Password</label>
|
||
<input type="password" id="admin-pass" required minlength="6">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>🖥️ IPMI Connection (Required)</h2>
|
||
<div class="form-group">
|
||
<label>IPMI Host/IP</label>
|
||
<input type="text" id="ipmi-host" placeholder="192.168.1.100" required>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Username</label>
|
||
<input type="text" id="ipmi-user" value="root" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Password</label>
|
||
<input type="password" id="ipmi-pass" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Port</label>
|
||
<input type="number" id="ipmi-port" value="623">
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit">Complete Setup</button>
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
document.getElementById('setup-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const errorEl = document.getElementById('error');
|
||
errorEl.style.display = 'none';
|
||
|
||
const data = {
|
||
admin_username: document.getElementById('admin-user').value,
|
||
admin_password: document.getElementById('admin-pass').value,
|
||
ipmi_host: document.getElementById('ipmi-host').value,
|
||
ipmi_username: document.getElementById('ipmi-user').value,
|
||
ipmi_password: document.getElementById('ipmi-pass').value,
|
||
ipmi_port: parseInt(document.getElementById('ipmi-port').value) || 623
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/setup', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(data)
|
||
});
|
||
const result = await res.json();
|
||
|
||
if (result.success) {
|
||
window.location.href = '/';
|
||
} else {
|
||
errorEl.textContent = result.error || 'Setup failed';
|
||
errorEl.style.display = 'block';
|
||
}
|
||
} catch (e) {
|
||
errorEl.textContent = 'Connection error: ' + e.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>'''
|
||
|
||
DASHBOARD_HTML = '''<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>IPMI Fan Controller v2</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
min-height: 100vh;
|
||
color: #fff;
|
||
padding: 20px;
|
||
}
|
||
.container { max-width: 1000px; margin: 0 auto; }
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 30px;
|
||
flex-wrap: wrap;
|
||
gap: 15px;
|
||
}
|
||
h1 { font-size: 1.6rem; }
|
||
.header-actions { display: flex; gap: 10px; }
|
||
|
||
.card {
|
||
background: rgba(255,255,255,0.05);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
.card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; display: flex; align-items: center; gap: 10px; }
|
||
|
||
.status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.status-item {
|
||
background: rgba(0,0,0,0.2);
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
.status-item .label { font-size: 0.8rem; color: #888; margin-bottom: 5px; }
|
||
.status-item .value { font-size: 1.4rem; font-weight: bold; }
|
||
.status-item .value.good { color: #4caf50; }
|
||
.status-item .value.warn { color: #ff9800; }
|
||
.status-item .value.bad { color: #f44336; }
|
||
|
||
.temp-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(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: #f44336; }
|
||
.temp-item .temp-value.med { color: #ff9800; }
|
||
.temp-item .temp-value.low { color: #4caf50; }
|
||
|
||
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; }
|
||
button {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.95rem;
|
||
transition: all 0.2s;
|
||
}
|
||
button.small { padding: 6px 12px; font-size: 0.85rem; }
|
||
button.primary { background: #2196f3; color: white; }
|
||
button.primary:hover { background: #1976d2; }
|
||
button.success { background: #4caf50; color: white; }
|
||
button.success:hover { background: #388e3c; }
|
||
button.danger { background: #f44336; color: white; }
|
||
button.danger:hover { background: #d32f2f; }
|
||
button.secondary { background: rgba(255,255,255,0.1); color: white; }
|
||
button.secondary:hover { background: rgba(255,255,255,0.2); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.slider-container { margin: 15px 0; }
|
||
.slider-container label { display: block; margin-bottom: 10px; }
|
||
input[type="range"] {
|
||
width: 100%;
|
||
height: 8px;
|
||
-webkit-appearance: none;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 4px;
|
||
outline: none;
|
||
}
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 22px;
|
||
height: 22px;
|
||
background: #2196f3;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.config-form { display: grid; gap: 15px; }
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 15px;
|
||
}
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
font-size: 0.85rem;
|
||
color: #aaa;
|
||
}
|
||
.form-group input, .form-group select {
|
||
width: 100%;
|
||
padding: 10px;
|
||
background: rgba(0,0,0,0.2);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 5px;
|
||
margin-bottom: 20px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||
padding-bottom: 10px;
|
||
}
|
||
.tab {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: none;
|
||
color: #888;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
}
|
||
.tab:hover { color: #fff; }
|
||
.tab.active { background: rgba(255,255,255,0.1); color: #fff; }
|
||
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
.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 {
|
||
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: #1a1a2e;
|
||
padding: 30px;
|
||
border-radius: 12px;
|
||
max-width: 400px;
|
||
width: 100%;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
.modal-content h3 { margin-bottom: 20px; }
|
||
|
||
.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: #4caf50; }
|
||
.toast.error { background: #f44336; }
|
||
@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-grid { grid-template-columns: repeat(2, 1fr); }
|
||
header { flex-direction: column; align-items: flex-start; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header>
|
||
<h1>🌬️ IPMI Fan Controller v2</h1>
|
||
<div class="header-actions">
|
||
<button class="secondary small" onclick="showPasswordModal()">🔑 Change Password</button>
|
||
<button class="secondary small" onclick="logout()">🚪 Logout</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="card">
|
||
<h2>📊 Current Status</h2>
|
||
<div class="status-grid">
|
||
<div class="status-item">
|
||
<div class="label">IPMI Connection</div>
|
||
<div class="value" id="conn-status">-</div>
|
||
</div>
|
||
<div class="status-item">
|
||
<div class="label">Control Mode</div>
|
||
<div class="value" id="mode-status">-</div>
|
||
</div>
|
||
<div class="status-item">
|
||
<div class="label">Current Speed</div>
|
||
<div class="value" id="current-speed">-</div>
|
||
</div>
|
||
<div class="status-item">
|
||
<div class="label">Max CPU Temp</div>
|
||
<div class="value" id="max-temp">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="temp-section" class="hidden">
|
||
<h3 style="margin:15px 0 10px;font-size:0.95rem;color:#aaa;">Temperature Sensors</h3>
|
||
<div class="temp-grid" id="temp-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>🎛️ Quick Controls</h2>
|
||
<div class="controls">
|
||
<button class="success" id="btn-auto" onclick="setAuto(true)">▶ Start Auto</button>
|
||
<button class="danger" id="btn-stop" onclick="setAuto(false)">⏹ Stop Auto</button>
|
||
<button class="primary" onclick="testConnection()">🔄 Test Connection</button>
|
||
</div>
|
||
|
||
<div class="slider-container">
|
||
<label>Manual Fan Speed: <strong id="manual-speed-val">50%</strong></label>
|
||
<input type="range" id="manual-speed" min="0" max="100" value="50">
|
||
<button class="primary" style="margin-top:10px;" onclick="setManualSpeed()">Apply Manual Speed</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="tabs">
|
||
<button class="tab active" onclick="showTab('ipmi')">🖥️ IPMI</button>
|
||
<button class="tab" onclick="showTab('ssh')">🔐 SSH</button>
|
||
<button class="tab" onclick="showTab('settings')">⚙️ Settings</button>
|
||
<button class="tab" onclick="showTab('curve')">📈 Fan Curve</button>
|
||
<button class="tab" onclick="showTab('logs')">📝 Logs</button>
|
||
</div>
|
||
|
||
<div id="tab-ipmi" class="tab-content active">
|
||
<h3 style="margin-bottom:15px;">IPMI Configuration</h3>
|
||
<div class="config-form">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>IPMI 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 (leave blank to keep current)</label>
|
||
<input type="password" id="cfg-ipmi-pass" placeholder="••••••••">
|
||
</div>
|
||
</div>
|
||
<button class="primary" onclick="saveIPMIConfig()">Save IPMI Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-ssh" class="tab-content">
|
||
<h3 style="margin-bottom:15px;">SSH Configuration (for lm-sensors)</h3>
|
||
<div class="config-form">
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="cfg-ssh-enabled" style="width:auto;margin-right:8px;"> Enable SSH Connection</label>
|
||
</div>
|
||
<div id="ssh-fields">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>SSH Host (defaults to IPMI host if empty)</label>
|
||
<input type="text" id="cfg-ssh-host" placeholder="192.168.1.100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Port</label>
|
||
<input type="number" id="cfg-ssh-port" value="22">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Username</label>
|
||
<input type="text" id="cfg-ssh-user" placeholder="root">
|
||
</div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="cfg-ssh-key" style="width:auto;margin-right:8px;"> Use SSH Key</label>
|
||
</div>
|
||
</div>
|
||
<div id="ssh-pass-field">
|
||
<div class="form-group">
|
||
<label>Password (leave blank to keep current)</label>
|
||
<input type="password" id="cfg-ssh-pass" placeholder="••••••••">
|
||
</div>
|
||
</div>
|
||
<div id="ssh-key-field" class="hidden">
|
||
<div class="form-group">
|
||
<label>SSH Private Key</label>
|
||
<textarea id="cfg-ssh-key-data" rows="6" style="width:100%;padding:10px;background:rgba(0,0,0,0.2);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#fff;font-family:monospace;font-size:0.85rem;" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="primary" onclick="saveSSHConfig()">Save SSH Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-settings" class="tab-content">
|
||
<h3 style="margin-bottom:15px;">Fan Control Settings</h3>
|
||
<div class="config-form">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Min Speed (%)</label>
|
||
<input type="number" id="cfg-min" value="10" min="0" max="100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Max Speed (%)</label>
|
||
<input type="number" id="cfg-max" value="100" min="0" max="100">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Panic Temp (°C)</label>
|
||
<input type="number" id="cfg-panic-temp" value="85" min="50" max="100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Check Interval (sec)</label>
|
||
<input type="number" id="cfg-interval" value="10" min="5" max="300">
|
||
</div>
|
||
</div>
|
||
<button class="primary" onclick="saveSettings()">Save Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-curve" class="tab-content">
|
||
<h3 style="margin-bottom:15px;">Fan Curve: Temperature → Speed</h3>
|
||
<div id="curve-editor" style="margin-bottom:15px;">
|
||
<div id="curve-points"></div>
|
||
<button class="secondary" style="margin-top:10px;" onclick="addCurvePoint()">+ Add Point</button>
|
||
</div>
|
||
<button class="primary" onclick="saveCurve()">Save Fan Curve</button>
|
||
</div>
|
||
|
||
<div id="tab-logs" class="tab-content">
|
||
<h3 style="margin-bottom:15px;">System Logs</h3>
|
||
<div class="log-output" id="logs">Ready...</div>
|
||
<div style="margin-top:10px;">
|
||
<button class="secondary small" onclick="clearLogs()">Clear</button>
|
||
<button class="secondary small" onclick="fetchLogs()">Refresh</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="password-modal">
|
||
<div class="modal-content">
|
||
<h3>🔑 Change Password</h3>
|
||
<div class="config-form">
|
||
<div class="form-group">
|
||
<label>Current Password</label>
|
||
<input type="password" id="pwd-current">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>New Password</label>
|
||
<input type="password" id="pwd-new" minlength="6">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Confirm New Password</label>
|
||
<input type="password" id="pwd-confirm">
|
||
</div>
|
||
<div style="display:flex;gap:10px;">
|
||
<button class="secondary" style="flex:1;" onclick="hidePasswordModal()">Cancel</button>
|
||
<button class="primary" style="flex:1;" onclick="changePassword()">Change</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentStatus = {};
|
||
let currentConfig = {};
|
||
|
||
function getToken() {
|
||
return localStorage.getItem('token') || '';
|
||
}
|
||
|
||
async function api(url, opts = {}) {
|
||
opts.headers = opts.headers || {};
|
||
opts.headers['Authorization'] = 'Bearer ' + getToken();
|
||
opts.headers['Content-Type'] = 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;
|
||
}
|
||
|
||
async function fetchStatus() {
|
||
const res = await api('/api/status');
|
||
if (!res) return;
|
||
currentStatus = await res.json();
|
||
updateUI();
|
||
}
|
||
|
||
function updateUI() {
|
||
const connEl = document.getElementById('conn-status');
|
||
if (currentStatus.connected) {
|
||
connEl.textContent = '✓ Connected';
|
||
connEl.className = 'value good';
|
||
} else {
|
||
connEl.textContent = '✗ Disconnected';
|
||
connEl.className = 'value bad';
|
||
}
|
||
|
||
const modeEl = document.getElementById('mode-status');
|
||
if (currentStatus.enabled) {
|
||
modeEl.textContent = 'AUTO';
|
||
modeEl.className = 'value good';
|
||
} else if (currentStatus.manual_mode) {
|
||
modeEl.textContent = 'MANUAL';
|
||
modeEl.className = 'value warn';
|
||
} else {
|
||
modeEl.textContent = 'AUTO (BIOS)';
|
||
modeEl.className = 'value';
|
||
}
|
||
|
||
document.getElementById('current-speed').textContent = currentStatus.current_speed + '%';
|
||
|
||
const temps = currentStatus.temperatures || [];
|
||
const cpuTemps = temps.filter(t => t.location.includes('cpu'));
|
||
if (cpuTemps.length > 0) {
|
||
const maxTemp = Math.max(...cpuTemps.map(t => t.value));
|
||
const tempEl = document.getElementById('max-temp');
|
||
tempEl.textContent = maxTemp.toFixed(1) + '°C';
|
||
tempEl.className = 'value ' + (maxTemp > 70 ? 'bad' : maxTemp > 50 ? 'warn' : 'good');
|
||
}
|
||
|
||
const tempList = document.getElementById('temp-list');
|
||
tempList.innerHTML = temps.map(t => {
|
||
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'med' : 'low';
|
||
return `<div class="temp-item">
|
||
<span>${t.name}</span>
|
||
<span class="temp-value ${cls}">${t.value.toFixed(1)}°C</span>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('temp-section').classList.toggle('hidden', temps.length === 0);
|
||
|
||
// Update config fields
|
||
if (currentStatus.config) {
|
||
currentConfig = currentStatus.config;
|
||
updateConfigFields();
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// SSH
|
||
document.getElementById('cfg-ssh-enabled').checked = cfg.ssh_enabled || false;
|
||
document.getElementById('ssh-fields').style.display = cfg.ssh_enabled ? 'block' : 'none';
|
||
if (cfg.ssh_host) document.getElementById('cfg-ssh-host').value = cfg.ssh_host;
|
||
if (cfg.ssh_port) document.getElementById('cfg-ssh-port').value = cfg.ssh_port;
|
||
if (cfg.ssh_username) document.getElementById('cfg-ssh-user').value = cfg.ssh_username;
|
||
document.getElementById('cfg-ssh-key').checked = cfg.ssh_use_key || false;
|
||
document.getElementById('ssh-pass-field').classList.toggle('hidden', cfg.ssh_use_key);
|
||
document.getElementById('ssh-key-field').classList.toggle('hidden', !cfg.ssh_use_key);
|
||
|
||
// Settings
|
||
if (cfg.min_speed !== undefined) document.getElementById('cfg-min').value = cfg.min_speed;
|
||
if (cfg.max_speed !== undefined) document.getElementById('cfg-max').value = cfg.max_speed;
|
||
if (cfg.panic_temp) document.getElementById('cfg-panic-temp').value = cfg.panic_temp;
|
||
if (cfg.interval) document.getElementById('cfg-interval').value = cfg.interval;
|
||
|
||
// Curve
|
||
updateCurveEditor();
|
||
}
|
||
|
||
function updateCurveEditor() {
|
||
const curve = currentConfig.fan_curve || [
|
||
{temp: 30, speed: 15}, {temp: 40, speed: 25}, {temp: 50, speed: 40},
|
||
{temp: 60, speed: 60}, {temp: 70, speed: 80}, {temp: 80, speed: 100}
|
||
];
|
||
const container = document.getElementById('curve-points');
|
||
container.innerHTML = curve.map((p, i) => `
|
||
<div class="form-row" style="margin-bottom:8px;">
|
||
<input type="number" class="curve-temp" data-idx="${i}" value="${p.temp}" min="0" max="100" placeholder="Temp °C">
|
||
<input type="number" class="curve-speed" data-idx="${i}" value="${p.speed}" min="0" max="100" placeholder="Speed %">
|
||
<button class="danger small" onclick="this.parentElement.remove()" style="padding:8px 12px;">×</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addCurvePoint() {
|
||
const container = document.getElementById('curve-points');
|
||
const idx = container.children.length;
|
||
const div = document.createElement('div');
|
||
div.className = 'form-row';
|
||
div.style.marginBottom = '8px';
|
||
div.innerHTML = `
|
||
<input type="number" class="curve-temp" data-idx="${idx}" value="50" min="0" max="100" placeholder="Temp °C">
|
||
<input type="number" class="curve-speed" data-idx="${idx}" value="50" min="0" max="100" placeholder="Speed %">
|
||
<button class="danger small" onclick="this.parentElement.remove()" style="padding:8px 12px;">×</button>
|
||
`;
|
||
container.appendChild(div);
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// SSH toggle handlers
|
||
document.getElementById('cfg-ssh-enabled').addEventListener('change', (e) => {
|
||
document.getElementById('ssh-fields').style.display = e.target.checked ? 'block' : 'none';
|
||
});
|
||
document.getElementById('cfg-ssh-key').addEventListener('change', (e) => {
|
||
document.getElementById('ssh-pass-field').classList.toggle('hidden', e.target.checked);
|
||
document.getElementById('ssh-key-field').classList.toggle('hidden', !e.target.checked);
|
||
});
|
||
|
||
// Slider handler
|
||
document.getElementById('manual-speed').addEventListener('input', (e) => {
|
||
document.getElementById('manual-speed-val').textContent = e.target.value + '%';
|
||
});
|
||
|
||
async function testConnection() {
|
||
log('Testing IPMI connection...');
|
||
const res = await api('/api/test', { method: 'POST' });
|
||
if (!res) return;
|
||
const data = await res.json();
|
||
log(data.success ? '✓ Connection successful' : '✗ Connection 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 control enabled' : 'Auto control disabled') : '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})
|
||
});
|
||
if (!res) return;
|
||
const data = await res.json();
|
||
log(data.success ? `Manual speed set to ${speed}%` : 'Failed: ' + data.error, data.success ? 'success' : 'error');
|
||
fetchStatus();
|
||
}
|
||
|
||
async function saveIPMIConfig() {
|
||
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) });
|
||
if (!res) return;
|
||
const result = await res.json();
|
||
log(result.success ? 'IPMI settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
|
||
if (result.success) {
|
||
document.getElementById('cfg-ipmi-pass').value = '';
|
||
fetchStatus();
|
||
}
|
||
}
|
||
|
||
async function saveSSHConfig() {
|
||
const data = {
|
||
enabled: document.getElementById('cfg-ssh-enabled').checked,
|
||
host: document.getElementById('cfg-ssh-host').value || undefined,
|
||
username: document.getElementById('cfg-ssh-user').value || undefined,
|
||
password: document.getElementById('cfg-ssh-pass').value || undefined,
|
||
use_key: document.getElementById('cfg-ssh-key').checked,
|
||
key_data: document.getElementById('cfg-ssh-key-data').value || undefined
|
||
};
|
||
const res = await api('/api/config/ssh', { method: 'POST', body: JSON.stringify(data) });
|
||
if (!res) return;
|
||
const result = await res.json();
|
||
log(result.success ? 'SSH settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
|
||
if (result.success) {
|
||
document.getElementById('cfg-ssh-pass').value = '';
|
||
document.getElementById('cfg-ssh-key-data').value = '';
|
||
fetchStatus();
|
||
}
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const data = {
|
||
min_speed: parseInt(document.getElementById('cfg-min').value),
|
||
max_speed: parseInt(document.getElementById('cfg-max').value),
|
||
panic_temp: parseFloat(document.getElementById('cfg-panic-temp').value),
|
||
interval: parseInt(document.getElementById('cfg-interval').value)
|
||
};
|
||
const res = await api('/api/config/settings', { method: 'POST', body: JSON.stringify(data) });
|
||
if (!res) return;
|
||
const result = await res.json();
|
||
log(result.success ? 'Settings saved' : 'Failed: ' + result.error, result.success ? 'success' : 'error');
|
||
fetchStatus();
|
||
}
|
||
|
||
async function saveCurve() {
|
||
const points = [];
|
||
document.querySelectorAll('.curve-temp').forEach((el, i) => {
|
||
const temp = parseFloat(el.value);
|
||
const speedEl = document.querySelector(`.curve-speed[data-idx="${i}"]`);
|
||
if (speedEl) {
|
||
points.push({temp, speed: parseInt(speedEl.value)});
|
||
}
|
||
});
|
||
const res = await api('/api/config/curve', {
|
||
method: 'POST',
|
||
body: JSON.stringify({points})
|
||
});
|
||
if (!res) return;
|
||
const data = await res.json();
|
||
log(data.success ? 'Fan curve saved' : 'Failed: ' + data.error, data.success ? 'success' : 'error');
|
||
fetchStatus();
|
||
}
|
||
|
||
function showPasswordModal() {
|
||
document.getElementById('password-modal').classList.add('active');
|
||
}
|
||
|
||
function hidePasswordModal() {
|
||
document.getElementById('password-modal').classList.remove('active');
|
||
document.getElementById('pwd-current').value = '';
|
||
document.getElementById('pwd-new').value = '';
|
||
document.getElementById('pwd-confirm').value = '';
|
||
}
|
||
|
||
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})
|
||
});
|
||
if (!res) return;
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
log('Password changed successfully', 'success');
|
||
hidePasswordModal();
|
||
} 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 = '';
|
||
}
|
||
|
||
function fetchLogs() {
|
||
// Logs are updated in real-time via status updates
|
||
log('Logs refreshed');
|
||
}
|
||
|
||
// Init
|
||
setInterval(fetchStatus, 3000);
|
||
fetchStatus();
|
||
</script>
|
||
</body>
|
||
</html>'''
|
||
|
||
# FastAPI app
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""Application lifespan handler."""
|
||
# Ensure data directory exists
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
yield
|
||
|
||
app = FastAPI(
|
||
title="IPMI Fan Controller v2",
|
||
description="Fan control for Dell servers with auth and SSH support",
|
||
version="2.1.0",
|
||
lifespan=lifespan
|
||
)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Routes
|
||
@app.get("/")
|
||
async def root(request: Request):
|
||
"""Main dashboard or redirect to setup/login."""
|
||
# Check if setup is needed
|
||
if not user_manager.is_setup_complete():
|
||
return HTMLResponse(content=SETUP_HTML)
|
||
|
||
# Check auth
|
||
token = request.cookies.get("token") or request.headers.get("authorization", "").replace("Bearer ", "")
|
||
if not token or not user_manager.verify_token(token):
|
||
return HTMLResponse(content=LOGIN_HTML)
|
||
|
||
return HTMLResponse(content=DASHBOARD_HTML)
|
||
|
||
@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):
|
||
"""Login and get token."""
|
||
if not user_manager.verify_user(credentials.username, credentials.password):
|
||
return {"success": False, "error": "Invalid username or password"}
|
||
|
||
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)):
|
||
"""Change current user's password."""
|
||
if not user_manager.change_password(username, data.current_password, data.new_password):
|
||
return {"success": False, "error": "Current password is incorrect"}
|
||
return {"success": True}
|
||
|
||
@app.post("/api/setup")
|
||
async def api_setup(data: SetupRequest):
|
||
"""Initial setup - create admin and configure IPMI."""
|
||
if user_manager.is_setup_complete():
|
||
return {"success": False, "error": "Setup already completed"}
|
||
|
||
# Create admin user
|
||
if not user_manager.create_user(data.admin_username, data.admin_password):
|
||
return {"success": False, "error": "Failed to create user"}
|
||
|
||
# Configure IPMI
|
||
service = get_service(str(CONFIG_FILE))
|
||
service.update_config(
|
||
ipmi_host=data.ipmi_host,
|
||
ipmi_username=data.ipmi_username,
|
||
ipmi_password=data.ipmi_password,
|
||
ipmi_port=data.ipmi_port
|
||
)
|
||
|
||
# Create token
|
||
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)):
|
||
"""Get current controller status."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
status = service.get_status()
|
||
return status
|
||
|
||
@app.post("/api/test")
|
||
async def api_test(username: str = Depends(get_current_user)):
|
||
"""Test IPMI connection."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
if not service.controller:
|
||
if not service._init_controller():
|
||
return {"success": False, "error": "Failed to initialize controller - check config"}
|
||
|
||
success = service.controller.test_connection()
|
||
return {"success": success, "error": None if success else "Connection failed"}
|
||
|
||
@app.post("/api/control/auto")
|
||
async def api_control_auto(data: dict, username: str = Depends(get_current_user)):
|
||
"""Enable/disable automatic control."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
enabled = data.get('enabled', False)
|
||
|
||
if enabled and not service.config.get('ipmi_host'):
|
||
return {"success": False, "error": "IPMI host not configured"}
|
||
|
||
service.set_auto_mode(enabled)
|
||
|
||
if enabled and not service.running:
|
||
if not service.start():
|
||
return {"success": False, "error": "Failed to start service"}
|
||
|
||
return {"success": True}
|
||
|
||
@app.post("/api/control/manual")
|
||
async def api_control_manual(req: ManualSpeedRequest, username: str = Depends(get_current_user)):
|
||
"""Set manual fan speed."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
|
||
if not service.controller:
|
||
if not service._init_controller():
|
||
return {"success": False, "error": "Failed to connect to server"}
|
||
|
||
if service.set_manual_speed(req.speed):
|
||
return {"success": True}
|
||
return {"success": False, "error": "Failed to set fan speed"}
|
||
|
||
# Config API
|
||
@app.post("/api/config/ipmi")
|
||
async def api_config_ipmi(data: IPMIConfig, username: str = Depends(get_current_user)):
|
||
"""Update IPMI configuration."""
|
||
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/ssh")
|
||
async def api_config_ssh(data: dict, username: str = Depends(get_current_user)):
|
||
"""Update SSH configuration."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
|
||
updates = {
|
||
"ssh_enabled": data.get('enabled', False),
|
||
"ssh_host": data.get('host'),
|
||
"ssh_username": data.get('username'),
|
||
"ssh_use_key": data.get('use_key', False),
|
||
"ssh_port": data.get('port', 22)
|
||
}
|
||
|
||
if data.get('password'):
|
||
updates["ssh_password"] = data['password']
|
||
|
||
# Handle SSH key
|
||
if data.get('use_key') and data.get('key_data'):
|
||
key_filename = f"ssh_key_{username}"
|
||
key_path = SSH_KEYS_DIR / key_filename
|
||
try:
|
||
with open(key_path, 'w') as f:
|
||
f.write(data['key_data'])
|
||
key_path.chmod(0o600)
|
||
updates["ssh_key_file"] = str(key_path)
|
||
except Exception as e:
|
||
return {"success": False, "error": f"Failed to save SSH key: {e}"}
|
||
|
||
service.update_config(**updates)
|
||
return {"success": True}
|
||
|
||
@app.post("/api/config/settings")
|
||
async def api_config_settings(data: FanSettings, username: str = Depends(get_current_user)):
|
||
"""Update fan control settings."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
|
||
updates = {}
|
||
if data.enabled is not None:
|
||
updates['enabled'] = data.enabled
|
||
if data.interval is not None:
|
||
updates['interval'] = data.interval
|
||
if data.min_speed is not None:
|
||
updates['min_speed'] = data.min_speed
|
||
if data.max_speed is not None:
|
||
updates['max_speed'] = data.max_speed
|
||
if data.panic_temp is not None:
|
||
updates['panic_temp'] = data.panic_temp
|
||
if data.panic_speed is not None:
|
||
updates['panic_speed'] = data.panic_speed
|
||
|
||
service.update_config(**updates)
|
||
return {"success": True}
|
||
|
||
@app.post("/api/config/curve")
|
||
async def api_config_curve(curve: FanCurveUpdate, username: str = Depends(get_current_user)):
|
||
"""Update fan curve."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
points = [{"temp": p.temp, "speed": p.speed} for p in curve.points]
|
||
service.update_config(fan_curve=points)
|
||
return {"success": True}
|
||
|
||
@app.post("/api/shutdown")
|
||
async def api_shutdown(username: str = Depends(get_current_user)):
|
||
"""Return fans to automatic control and stop service."""
|
||
service = get_service(str(CONFIG_FILE))
|
||
service.stop()
|
||
return {"success": True, "message": "Service stopped, fans returned to automatic control"}
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|