1153 lines
39 KiB
Python
1153 lines
39 KiB
Python
"""Main FastAPI application."""
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.config import settings
|
|
from backend.database import init_db, get_db, is_setup_complete, set_setup_complete, User, Server, FanCurve, SystemLog, SensorData, FanData
|
|
from backend.auth import (
|
|
verify_password, get_password_hash, create_access_token,
|
|
decode_access_token, encrypt_password, decrypt_password
|
|
)
|
|
from backend.ipmi_client import IPMIClient
|
|
from backend.fan_control import fan_controller, FanCurveManager, initialize_fan_controller, shutdown_fan_controller
|
|
from backend.schemas import (
|
|
UserCreate, UserLogin, UserResponse, Token, TokenData,
|
|
ServerCreate, ServerUpdate, ServerResponse, ServerDetailResponse, ServerStatusResponse,
|
|
FanCurveCreate, FanCurveResponse, FanCurvePoint,
|
|
SensorReading, TemperatureReading, FanReading, ServerSensorsResponse,
|
|
FanControlCommand, AutoControlSettings, FanCurveApply,
|
|
SystemLogResponse, SetupStatus, SetupComplete, DashboardStats, ServerDashboardData
|
|
)
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Security
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
db: Session = Depends(get_db)
|
|
) -> User:
|
|
"""Get current authenticated user."""
|
|
if not credentials:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Not authenticated",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
payload = decode_access_token(credentials.credentials)
|
|
if not payload:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
username: str = payload.get("sub")
|
|
if not username:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User is inactive"
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
async def get_current_user_optional(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
db: Session = Depends(get_db)
|
|
) -> Optional[User]:
|
|
"""Get current user if authenticated, None otherwise."""
|
|
try:
|
|
return await get_current_user(credentials, db)
|
|
except HTTPException:
|
|
return None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifespan handler."""
|
|
# Startup
|
|
logger.info("Starting up IPMI Fan Control...")
|
|
init_db()
|
|
await initialize_fan_controller()
|
|
yield
|
|
# Shutdown
|
|
logger.info("Shutting down IPMI Fan Control...")
|
|
await shutdown_fan_controller()
|
|
|
|
|
|
app = FastAPI(
|
|
title=settings.APP_NAME,
|
|
description="IPMI Fan Control for Dell T710 and compatible servers",
|
|
version="1.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # Configure for production
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# Setup wizard endpoints
|
|
@app.get("/api/setup/status", response_model=SetupStatus)
|
|
async def setup_status(db: Session = Depends(get_db)):
|
|
"""Check if initial setup is complete."""
|
|
return {"setup_complete": is_setup_complete(db)}
|
|
|
|
|
|
@app.post("/api/setup/complete", response_model=UserResponse)
|
|
async def complete_setup(setup_data: SetupComplete, db: Session = Depends(get_db)):
|
|
"""Complete initial setup by creating admin user."""
|
|
if is_setup_complete(db):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Setup already completed"
|
|
)
|
|
|
|
# Create admin user
|
|
hashed_password = get_password_hash(setup_data.password)
|
|
user = User(
|
|
username=setup_data.username,
|
|
hashed_password=hashed_password,
|
|
is_active=True
|
|
)
|
|
db.add(user)
|
|
|
|
# Mark setup as complete
|
|
set_setup_complete(db, True)
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
logger.info(f"Setup completed - admin user '{setup_data.username}' created")
|
|
return user
|
|
|
|
|
|
# Authentication endpoints
|
|
@app.post("/api/auth/login", response_model=Token)
|
|
async def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
|
"""Login and get access token."""
|
|
user = db.query(User).filter(User.username == credentials.username).first()
|
|
|
|
if not user or not verify_password(credentials.password, user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User is inactive"
|
|
)
|
|
|
|
# Update last login
|
|
user.last_login = datetime.utcnow()
|
|
db.commit()
|
|
|
|
access_token = create_access_token(data={"sub": user.username})
|
|
return {"access_token": access_token, "token_type": "bearer"}
|
|
|
|
|
|
@app.get("/api/auth/me", response_model=UserResponse)
|
|
async def get_me(current_user: User = Depends(get_current_user)):
|
|
"""Get current user info."""
|
|
return current_user
|
|
|
|
|
|
# Server management endpoints
|
|
@app.get("/api/servers", response_model=List[ServerResponse])
|
|
async def list_servers(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List all servers."""
|
|
return db.query(Server).all()
|
|
|
|
|
|
@app.post("/api/servers", response_model=ServerResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_server(
|
|
server_data: ServerCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add a new server."""
|
|
# Encrypt IPMI password
|
|
ipmi_encrypted_password = encrypt_password(server_data.ipmi.ipmi_password)
|
|
|
|
# Encrypt SSH password if provided
|
|
ssh_encrypted_password = None
|
|
if server_data.ssh.ssh_password:
|
|
ssh_encrypted_password = encrypt_password(server_data.ssh.ssh_password)
|
|
|
|
server = Server(
|
|
name=server_data.name,
|
|
# IPMI settings
|
|
ipmi_host=server_data.ipmi.ipmi_host,
|
|
ipmi_port=server_data.ipmi.ipmi_port,
|
|
ipmi_username=server_data.ipmi.ipmi_username,
|
|
ipmi_encrypted_password=ipmi_encrypted_password,
|
|
# SSH settings
|
|
ssh_host=server_data.ssh.ssh_host or server_data.ipmi.ipmi_host,
|
|
ssh_port=server_data.ssh.ssh_port,
|
|
ssh_username=server_data.ssh.ssh_username,
|
|
ssh_encrypted_password=ssh_encrypted_password,
|
|
ssh_key_file=server_data.ssh.ssh_key_file,
|
|
use_ssh=server_data.ssh.use_ssh,
|
|
vendor=server_data.vendor
|
|
)
|
|
db.add(server)
|
|
db.commit()
|
|
db.refresh(server)
|
|
|
|
logger.info(f"Server '{server.name}' added by {current_user.username}")
|
|
return server
|
|
|
|
|
|
@app.get("/api/servers/{server_id}", response_model=ServerDetailResponse)
|
|
async def get_server(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get server details."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
return server
|
|
|
|
|
|
@app.put("/api/servers/{server_id}", response_model=ServerResponse)
|
|
async def update_server(
|
|
server_id: int,
|
|
server_data: ServerUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update server configuration."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
update_data = server_data.dict(exclude_unset=True)
|
|
|
|
# Handle IPMI password update separately
|
|
if "ipmi_password" in update_data:
|
|
server.ipmi_encrypted_password = encrypt_password(update_data.pop("ipmi_password"))
|
|
|
|
# Handle SSH password update separately
|
|
if "ssh_password" in update_data:
|
|
if update_data["ssh_password"]:
|
|
server.ssh_encrypted_password = encrypt_password(update_data.pop("ssh_password"))
|
|
else:
|
|
update_data.pop("ssh_password")
|
|
|
|
# Update other fields
|
|
for field, value in update_data.items():
|
|
if hasattr(server, field):
|
|
setattr(server, field, value)
|
|
|
|
db.commit()
|
|
db.refresh(server)
|
|
|
|
logger.info(f"Server '{server.name}' updated by {current_user.username}")
|
|
return server
|
|
|
|
|
|
@app.delete("/api/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_server(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete a server."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Stop fan control if running
|
|
await fan_controller.stop_server_control(server_id)
|
|
|
|
db.delete(server)
|
|
db.commit()
|
|
|
|
logger.info(f"Server '{server.name}' deleted by {current_user.username}")
|
|
return None
|
|
|
|
|
|
@app.get("/api/servers/{server_id}/status", response_model=ServerStatusResponse)
|
|
async def get_server_status(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get server connection and controller status."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Test connection
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
is_connected = client.test_connection()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to connect to server {server.name}: {e}")
|
|
is_connected = False
|
|
|
|
return {
|
|
"server": server,
|
|
"is_connected": is_connected,
|
|
"controller_status": fan_controller.get_controller_status(server_id)
|
|
}
|
|
|
|
|
|
# Sensor data endpoints
|
|
@app.get("/api/servers/{server_id}/sensors", response_model=ServerSensorsResponse)
|
|
async def get_server_sensors(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current sensor readings from server.
|
|
|
|
Uses cached data from the continuous sensor collector for fast response.
|
|
Cache is updated every 10 seconds.
|
|
"""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Try cache first
|
|
cached = await sensor_cache.get(server_id)
|
|
if cached:
|
|
logger.info(f"Serving sensors for {server.name} from cache")
|
|
# Data is already in correct format from collector
|
|
return {
|
|
"server_id": server_id,
|
|
"temperatures": cached.get("temps", []),
|
|
"fans": cached.get("fans", []),
|
|
"all_sensors": [],
|
|
"timestamp": cached.get("timestamp", datetime.utcnow().isoformat())
|
|
}
|
|
|
|
# Cache miss - fetch live data
|
|
logger.warning(f"Cache miss for sensors {server.name}, fetching live")
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
temps = client.get_temperatures()
|
|
fans = client.get_fan_speeds()
|
|
all_sensors = client.get_all_sensors()
|
|
|
|
return {
|
|
"server_id": server_id,
|
|
"temperatures": [t.__dict__ for t in temps],
|
|
"fans": [f.__dict__ for f in fans],
|
|
"all_sensors": [s.__dict__ for s in all_sensors],
|
|
"timestamp": datetime.utcnow()
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get sensors from {server.name}: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to get sensor data: {str(e)}")
|
|
|
|
|
|
@app.get("/api/servers/{server_id}/power", response_model=dict)
|
|
async def get_server_power(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get power consumption data from cache.
|
|
|
|
Data is updated every 10 seconds by the sensor collector.
|
|
"""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Try cache first
|
|
cached = await sensor_cache.get(server_id)
|
|
if cached and cached.get("power_raw"):
|
|
return cached["power_raw"]
|
|
|
|
# Cache miss - fetch live
|
|
logger.warning(f"Cache miss for power {server.name}, fetching live")
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
power_data = client.get_power_consumption()
|
|
if power_data is None:
|
|
raise HTTPException(status_code=500, detail="Power data not available")
|
|
|
|
return power_data
|
|
except Exception as e:
|
|
logger.error(f"Failed to get power data from {server.name}: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to get power data: {str(e)}")
|
|
|
|
|
|
# Fan control endpoints
|
|
@app.post("/api/servers/{server_id}/fans/manual/enable")
|
|
async def enable_manual_fan_control(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Enable manual fan control."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
if client.enable_manual_fan_control():
|
|
server.manual_control_enabled = True
|
|
server.auto_control_enabled = False # Disable auto when manual enabled
|
|
db.commit()
|
|
|
|
# Log event
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="fan_change",
|
|
message="Manual fan control enabled",
|
|
details=f"Enabled by {current_user.username}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Manual fan control enabled"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to enable manual fan control")
|
|
except Exception as e:
|
|
logger.error(f"Failed to enable manual control on {server.name}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/servers/{server_id}/fans/manual/disable")
|
|
async def disable_manual_fan_control(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Disable manual fan control (return to automatic)."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
if client.disable_manual_fan_control():
|
|
server.manual_control_enabled = False
|
|
server.auto_control_enabled = False
|
|
db.commit()
|
|
|
|
# Stop auto control task if running
|
|
await fan_controller.stop_server_control(server_id)
|
|
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="fan_change",
|
|
message="Manual fan control disabled (automatic mode)",
|
|
details=f"Disabled by {current_user.username}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Fan control returned to automatic"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to disable manual fan control")
|
|
except Exception as e:
|
|
logger.error(f"Failed to disable manual control on {server.name}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/servers/{server_id}/fans/set")
|
|
async def set_fan_speed(
|
|
server_id: int,
|
|
command: FanControlCommand,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Set fan speed manually."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
if not server.manual_control_enabled:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Manual fan control is not enabled. Enable it first."
|
|
)
|
|
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
if client.set_fan_speed(command.fan_id, command.speed_percent):
|
|
fan_desc = f"Fan {command.fan_id}" if command.fan_id != "0xff" else "All fans"
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="fan_change",
|
|
message=f"{fan_desc} speed set to {command.speed_percent}%",
|
|
details=f"Set by {current_user.username}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": f"Fan speed set to {command.speed_percent}%"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to set fan speed")
|
|
except Exception as e:
|
|
logger.error(f"Failed to set fan speed on {server.name}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# Fan curve endpoints
|
|
@app.get("/api/servers/{server_id}/fan-curves", response_model=List[FanCurveResponse])
|
|
async def list_fan_curves(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List fan curves for a server."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
return db.query(FanCurve).filter(FanCurve.server_id == server_id).all()
|
|
|
|
|
|
@app.post("/api/servers/{server_id}/fan-curves", response_model=FanCurveResponse)
|
|
async def create_fan_curve(
|
|
server_id: int,
|
|
curve_data: FanCurveCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new fan curve."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
curve = FanCurve(
|
|
server_id=server_id,
|
|
name=curve_data.name,
|
|
curve_data=FanCurveManager.serialize_curve([FanCurvePoint(temp=p.temp, speed=p.speed) for p in curve_data.curve_data]),
|
|
sensor_source=curve_data.sensor_source,
|
|
is_active=curve_data.is_active
|
|
)
|
|
db.add(curve)
|
|
db.commit()
|
|
db.refresh(curve)
|
|
|
|
return curve
|
|
|
|
|
|
@app.put("/api/servers/{server_id}/fan-curves/{curve_id}", response_model=FanCurveResponse)
|
|
async def update_fan_curve(
|
|
server_id: int,
|
|
curve_id: int,
|
|
curve_data: FanCurveCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update a fan curve."""
|
|
curve = db.query(FanCurve).filter(
|
|
FanCurve.id == curve_id,
|
|
FanCurve.server_id == server_id
|
|
).first()
|
|
|
|
if not curve:
|
|
raise HTTPException(status_code=404, detail="Fan curve not found")
|
|
|
|
curve.name = curve_data.name
|
|
curve.curve_data = FanCurveManager.serialize_curve([FanCurvePoint(temp=p.temp, speed=p.speed) for p in curve_data.curve_data])
|
|
curve.sensor_source = curve_data.sensor_source
|
|
curve.is_active = curve_data.is_active
|
|
|
|
db.commit()
|
|
db.refresh(curve)
|
|
|
|
return curve
|
|
|
|
|
|
@app.delete("/api/servers/{server_id}/fan-curves/{curve_id}")
|
|
async def delete_fan_curve(
|
|
server_id: int,
|
|
curve_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete a fan curve."""
|
|
curve = db.query(FanCurve).filter(
|
|
FanCurve.id == curve_id,
|
|
FanCurve.server_id == server_id
|
|
).first()
|
|
|
|
if not curve:
|
|
raise HTTPException(status_code=404, detail="Fan curve not found")
|
|
|
|
db.delete(curve)
|
|
db.commit()
|
|
|
|
return {"success": True}
|
|
|
|
|
|
# Auto control endpoints
|
|
@app.post("/api/servers/{server_id}/auto-control/enable")
|
|
async def enable_auto_control(
|
|
server_id: int,
|
|
settings_data: AutoControlSettings,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Enable automatic fan control with fan curve."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Set fan curve if specified
|
|
if settings_data.curve_id:
|
|
curve = db.query(FanCurve).filter(
|
|
FanCurve.id == settings_data.curve_id,
|
|
FanCurve.server_id == server_id
|
|
).first()
|
|
if curve:
|
|
server.fan_curve_data = curve.curve_data
|
|
|
|
server.auto_control_enabled = True
|
|
server.manual_control_enabled = True # Auto control requires manual mode
|
|
db.commit()
|
|
|
|
# Start the controller
|
|
await fan_controller.start_server_control(server_id)
|
|
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="fan_change",
|
|
message="Automatic fan control enabled",
|
|
details=f"Enabled by {current_user.username}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Automatic fan control enabled"}
|
|
|
|
|
|
@app.post("/api/servers/{server_id}/auto-control/disable")
|
|
async def disable_auto_control(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Disable automatic fan control."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
server.auto_control_enabled = False
|
|
db.commit()
|
|
|
|
# Stop the controller
|
|
await fan_controller.stop_server_control(server_id)
|
|
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="fan_change",
|
|
message="Automatic fan control disabled",
|
|
details=f"Disabled by {current_user.username}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Automatic fan control disabled"}
|
|
|
|
|
|
# Sensor data cache with TTL
|
|
|
|
class SensorCache:
|
|
"""Simple TTL cache for sensor data to reduce IPMI/SSH overhead."""
|
|
|
|
def __init__(self, ttl_seconds: int = 45):
|
|
self._cache: Dict[int, Dict[str, Any]] = {}
|
|
self._ttl = ttl_seconds
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def get(self, server_id: int) -> Optional[Dict[str, Any]]:
|
|
async with self._lock:
|
|
entry = self._cache.get(server_id)
|
|
if entry:
|
|
if datetime.utcnow() < entry['expires_at']:
|
|
return entry['data']
|
|
else:
|
|
del self._cache[server_id]
|
|
return None
|
|
|
|
async def set(self, server_id: int, data: Dict[str, Any]):
|
|
async with self._lock:
|
|
self._cache[server_id] = {
|
|
'data': data,
|
|
'expires_at': datetime.utcnow() + timedelta(seconds=self._ttl)
|
|
}
|
|
|
|
async def invalidate(self, server_id: int):
|
|
async with self._lock:
|
|
self._cache.pop(server_id, None)
|
|
|
|
# Global sensor cache
|
|
sensor_cache = SensorCache(ttl_seconds=10)
|
|
|
|
|
|
# Dashboard endpoints
|
|
@app.get("/api/dashboard/stats", response_model=DashboardStats)
|
|
async def get_dashboard_stats(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get dashboard statistics."""
|
|
servers = db.query(Server).all()
|
|
|
|
total_servers = len(servers)
|
|
active_servers = sum(1 for s in servers if s.is_active)
|
|
manual_servers = sum(1 for s in servers if s.manual_control_enabled)
|
|
auto_servers = sum(1 for s in servers if s.auto_control_enabled)
|
|
|
|
# Count servers in panic mode (this is a simplified check)
|
|
panic_servers = 0
|
|
for server in servers:
|
|
status = fan_controller.get_controller_status(server.id)
|
|
if status.get("state") == "panic":
|
|
panic_servers += 1
|
|
|
|
# Get recent logs (use index on timestamp)
|
|
recent_logs = db.query(SystemLog).order_by(SystemLog.timestamp.desc()).limit(10).all()
|
|
|
|
return {
|
|
"total_servers": total_servers,
|
|
"active_servers": active_servers,
|
|
"manual_control_servers": manual_servers,
|
|
"auto_control_servers": auto_servers,
|
|
"panic_mode_servers": panic_servers,
|
|
"recent_logs": recent_logs
|
|
}
|
|
|
|
|
|
@app.get("/api/dashboard/servers-overview")
|
|
async def get_servers_overview(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get a lightweight overview of all servers for the dashboard grid.
|
|
|
|
Returns cached data from the continuous sensor collector.
|
|
Data is updated every 10 seconds automatically.
|
|
"""
|
|
servers = db.query(Server).all()
|
|
|
|
async def get_server_status(server: Server) -> Dict[str, Any]:
|
|
# Try cache first - sensor collector updates this every 30 seconds
|
|
cached = await sensor_cache.get(server.id)
|
|
if cached:
|
|
logger.debug(f"Serving overview for {server.name} from cache")
|
|
return {
|
|
"id": server.id,
|
|
"name": server.name,
|
|
"vendor": server.vendor,
|
|
"is_active": server.is_active,
|
|
"manual_control_enabled": server.manual_control_enabled,
|
|
"auto_control_enabled": server.auto_control_enabled,
|
|
"max_temp": cached.get("max_temp"),
|
|
"avg_fan_speed": cached.get("avg_fan_speed"),
|
|
"power_consumption": cached.get("power_consumption"),
|
|
"last_updated": cached.get("timestamp"),
|
|
"cached": True
|
|
}
|
|
|
|
# No cache yet (sensor collector may not have run yet)
|
|
return {
|
|
"id": server.id,
|
|
"name": server.name,
|
|
"vendor": server.vendor,
|
|
"is_active": server.is_active,
|
|
"manual_control_enabled": server.manual_control_enabled,
|
|
"auto_control_enabled": server.auto_control_enabled,
|
|
"max_temp": None,
|
|
"avg_fan_speed": None,
|
|
"power_consumption": None,
|
|
"last_updated": None,
|
|
"cached": False
|
|
}
|
|
|
|
# Gather all server statuses concurrently
|
|
server_statuses = await asyncio.gather(*[
|
|
get_server_status(server) for server in servers
|
|
])
|
|
|
|
return {"servers": server_statuses}
|
|
|
|
|
|
@app.post("/api/dashboard/refresh-server/{server_id}")
|
|
async def refresh_server_data(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Manually trigger a sensor data refresh for a server.
|
|
|
|
The sensor collector updates data every 10 seconds automatically.
|
|
This endpoint allows forcing an immediate refresh.
|
|
"""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Trigger immediate collection via sensor_collector
|
|
from backend.fan_control import sensor_collector
|
|
await sensor_collector._collect_server_with_timeout(server)
|
|
|
|
return {"success": True, "message": "Data refreshed"}
|
|
|
|
|
|
@app.get("/api/dashboard/servers/{server_id}", response_model=ServerDashboardData)
|
|
async def get_server_dashboard(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get detailed dashboard data for a specific server.
|
|
|
|
Uses cached sensor data from the continuous collector.
|
|
Falls back to direct IPMI query only if cache is empty.
|
|
"""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Try to get sensor data from cache first
|
|
cached = await sensor_cache.get(server_id)
|
|
|
|
temps = []
|
|
fans = []
|
|
power_data = None
|
|
|
|
if cached:
|
|
# Use cached data - already in correct format
|
|
temps = cached.get("temps", [])
|
|
fans = cached.get("fans", [])
|
|
power_data = cached.get("power_raw")
|
|
logger.info(f"Serving dashboard data for {server.name} from cache")
|
|
else:
|
|
# Cache miss - fetch live data as fallback
|
|
logger.warning(f"Cache miss for server {server.name}, fetching live data")
|
|
try:
|
|
client = IPMIClient(
|
|
host=server.ipmi_host,
|
|
username=server.ipmi_username,
|
|
password=decrypt_password(server.ipmi_encrypted_password),
|
|
port=server.ipmi_port,
|
|
vendor=server.vendor
|
|
)
|
|
|
|
if client.test_connection():
|
|
temps_readings = client.get_temperatures()
|
|
temps = [{"name": t.name, "reading": t.value, "location": t.location} for t in temps_readings]
|
|
fans_readings = client.get_fan_speeds()
|
|
fans = [{"fan_number": f.fan_number, "reading": f.speed_percent, "speed_rpm": f.speed_rpm} for f in fans_readings]
|
|
power_data = client.get_power_consumption()
|
|
except Exception as e:
|
|
logger.warning(f"Could not fetch live data for {server.name}: {e}")
|
|
|
|
# Get recent historical data
|
|
recent_sensor_data = db.query(SensorData).filter(
|
|
SensorData.server_id == server_id
|
|
).order_by(SensorData.timestamp.desc()).limit(50).all()
|
|
|
|
recent_fan_data = db.query(FanData).filter(
|
|
FanData.server_id == server_id
|
|
).order_by(FanData.timestamp.desc()).limit(50).all()
|
|
|
|
return {
|
|
"server": server,
|
|
"current_temperatures": temps,
|
|
"current_fans": fans,
|
|
"recent_sensor_data": recent_sensor_data,
|
|
"recent_fan_data": recent_fan_data,
|
|
"power_consumption": power_data
|
|
}
|
|
|
|
|
|
# System logs endpoint
|
|
@app.get("/api/logs", response_model=List[SystemLogResponse])
|
|
async def get_system_logs(
|
|
server_id: Optional[int] = None,
|
|
event_type: Optional[str] = None,
|
|
limit: int = 100,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get system logs with optional filtering."""
|
|
query = db.query(SystemLog)
|
|
|
|
if server_id:
|
|
query = query.filter(SystemLog.server_id == server_id)
|
|
|
|
if event_type:
|
|
query = query.filter(SystemLog.event_type == event_type)
|
|
|
|
logs = query.order_by(SystemLog.timestamp.desc()).limit(limit).all()
|
|
return logs
|
|
|
|
|
|
# SSH endpoints
|
|
@app.get("/api/servers/{server_id}/ssh/sensors")
|
|
async def get_ssh_sensors(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get sensor data via SSH (lm-sensors)."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
if not server.use_ssh:
|
|
raise HTTPException(status_code=400, detail="SSH not enabled for this server")
|
|
|
|
try:
|
|
from backend.ssh_client import SSHClient
|
|
|
|
ssh_client = SSHClient(
|
|
host=server.ssh_host or server.ipmi_host,
|
|
username=server.ssh_username or server.ipmi_username,
|
|
password=decrypt_password(server.ssh_encrypted_password) if server.ssh_encrypted_password else None,
|
|
port=server.ssh_port
|
|
)
|
|
|
|
if not await ssh_client.connect():
|
|
raise HTTPException(status_code=500, detail="Failed to connect via SSH")
|
|
|
|
try:
|
|
cpu_temps = await ssh_client.get_cpu_temperatures()
|
|
raw_data = await ssh_client.get_lmsensors_data()
|
|
|
|
return {
|
|
"cpu_temps": [
|
|
{
|
|
"cpu_name": ct.cpu_name,
|
|
"core_temps": ct.core_temps,
|
|
"package_temp": ct.package_temp
|
|
}
|
|
for ct in cpu_temps
|
|
],
|
|
"raw_data": raw_data
|
|
}
|
|
finally:
|
|
await ssh_client.disconnect()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get SSH sensor data: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to get SSH sensor data: {str(e)}")
|
|
|
|
|
|
@app.get("/api/servers/{server_id}/ssh/system-info")
|
|
async def get_ssh_system_info(
|
|
server_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get system info via SSH."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
if not server.use_ssh:
|
|
raise HTTPException(status_code=400, detail="SSH not enabled for this server")
|
|
|
|
try:
|
|
from backend.ssh_client import SSHClient
|
|
|
|
ssh_client = SSHClient(
|
|
host=server.ssh_host or server.ipmi_host,
|
|
username=server.ssh_username or server.ipmi_username,
|
|
password=decrypt_password(server.ssh_encrypted_password) if server.ssh_encrypted_password else None,
|
|
port=server.ssh_port
|
|
)
|
|
|
|
if not await ssh_client.connect():
|
|
raise HTTPException(status_code=500, detail="Failed to connect via SSH")
|
|
|
|
try:
|
|
info = await ssh_client.get_system_info()
|
|
return info or {}
|
|
finally:
|
|
await ssh_client.disconnect()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get SSH system info: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to get system info: {str(e)}")
|
|
|
|
|
|
@app.post("/api/servers/{server_id}/ssh/execute")
|
|
async def execute_ssh_command(
|
|
server_id: int,
|
|
command_data: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Execute a command via SSH."""
|
|
server = db.query(Server).filter(Server.id == server_id).first()
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
if not server.use_ssh:
|
|
raise HTTPException(status_code=400, detail="SSH not enabled for this server")
|
|
|
|
command = command_data.get("command", "").strip()
|
|
if not command:
|
|
raise HTTPException(status_code=400, detail="Command is required")
|
|
|
|
# Log the command execution
|
|
log = SystemLog(
|
|
server_id=server_id,
|
|
event_type="info",
|
|
message="SSH command executed",
|
|
details=f"Command: {command[:50]}..." if len(command) > 50 else f"Command: {command}"
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
try:
|
|
from backend.ssh_client import SSHClient
|
|
|
|
ssh_client = SSHClient(
|
|
host=server.ssh_host or server.ipmi_host,
|
|
username=server.ssh_username or server.ipmi_username,
|
|
password=decrypt_password(server.ssh_encrypted_password) if server.ssh_encrypted_password else None,
|
|
port=server.ssh_port
|
|
)
|
|
|
|
if not await ssh_client.connect():
|
|
raise HTTPException(status_code=500, detail="Failed to connect via SSH")
|
|
|
|
try:
|
|
exit_code, stdout, stderr = await ssh_client.execute_command(command)
|
|
return {
|
|
"exit_code": exit_code,
|
|
"stdout": stdout,
|
|
"stderr": stderr
|
|
}
|
|
finally:
|
|
await ssh_client.disconnect()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to execute SSH command: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Command execution failed: {str(e)}")
|
|
|
|
|
|
# Health check endpoint (no auth required)
|
|
@app.get("/api/health")
|
|
async def health_check():
|
|
"""Health check endpoint."""
|
|
return {"status": "healthy"}
|
|
|
|
|
|
# Static files - serve frontend
|
|
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
|
if os.path.exists(frontend_path):
|
|
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_path, "assets")), name="assets")
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def serve_index():
|
|
return FileResponse(os.path.join(frontend_path, "index.html"))
|
|
|
|
@app.get("/{path:path}", include_in_schema=False)
|
|
async def serve_spa(path: str):
|
|
# For SPA routing, return index.html for all non-API routes
|
|
if not path.startswith("api/") and not path.startswith("assets/"):
|
|
file_path = os.path.join(frontend_path, path)
|
|
if os.path.exists(file_path) and os.path.isfile(file_path):
|
|
return FileResponse(file_path)
|
|
return FileResponse(os.path.join(frontend_path, "index.html"))
|
|
raise HTTPException(status_code=404)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|