Add SSH support for lm-sensors, dynamic fan detection, improved UI

This commit is contained in:
ImpulsiveFPS 2026-02-01 20:32:01 +01:00
parent 16c7d09a44
commit 505d19a439
11 changed files with 1352 additions and 308 deletions

View File

@ -45,10 +45,20 @@ class Server(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
host = Column(String(100), nullable=False) # IP address
port = Column(Integer, default=623) # IPMI Settings
username = Column(String(100), nullable=False) ipmi_host = Column(String(100), nullable=False) # IPMI IP address
encrypted_password = Column(String(255), nullable=False) # Encrypted password ipmi_port = Column(Integer, default=623)
ipmi_username = Column(String(100), nullable=False)
ipmi_encrypted_password = Column(String(255), nullable=False) # Encrypted password
# SSH Settings (for lm-sensors)
ssh_host = Column(String(100), nullable=True) # SSH host (can be same as IPMI or different)
ssh_port = Column(Integer, default=22)
ssh_username = Column(String(100), nullable=True)
ssh_encrypted_password = Column(String(255), nullable=True) # Encrypted password or use key
ssh_key_file = Column(String(255), nullable=True) # Path to SSH key file
use_ssh = Column(Boolean, default=False) # Whether to use SSH for sensor data
# Server type (dell, hpe, etc.) # Server type (dell, hpe, etc.)
vendor = Column(String(50), default="dell") vendor = Column(String(50), default="dell")

View File

@ -170,10 +170,10 @@ class FanController:
# Create IPMI client # Create IPMI client
from backend.auth import decrypt_password from backend.auth import decrypt_password
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )

View File

@ -211,15 +211,28 @@ async def create_server(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Add a new server.""" """Add a new server."""
# Encrypt password # Encrypt IPMI password
encrypted_password = encrypt_password(server_data.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( server = Server(
name=server_data.name, name=server_data.name,
host=server_data.host, # IPMI settings
port=server_data.port, ipmi_host=server_data.ipmi.ipmi_host,
username=server_data.username, ipmi_port=server_data.ipmi.ipmi_port,
encrypted_password=encrypted_password, 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 vendor=server_data.vendor
) )
db.add(server) db.add(server)
@ -257,9 +270,16 @@ async def update_server(
update_data = server_data.dict(exclude_unset=True) update_data = server_data.dict(exclude_unset=True)
# Handle password update separately # Handle IPMI password update separately
if "password" in update_data: if "ipmi_password" in update_data:
server.encrypted_password = encrypt_password(update_data.pop("password")) 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 # Update other fields
for field, value in update_data.items(): for field, value in update_data.items():
@ -308,10 +328,10 @@ async def get_server_status(
# Test connection # Test connection
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
is_connected = client.test_connection() is_connected = client.test_connection()
@ -340,10 +360,10 @@ async def get_server_sensors(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -377,10 +397,10 @@ async def get_server_power(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -408,10 +428,10 @@ async def enable_manual_fan_control(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -451,10 +471,10 @@ async def disable_manual_fan_control(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -503,10 +523,10 @@ async def set_fan_speed(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -559,7 +579,7 @@ async def create_fan_curve(
curve = FanCurve( curve = FanCurve(
server_id=server_id, server_id=server_id,
name=curve_data.name, name=curve_data.name,
curve_data=FanCurveManager.serialize_curve([FanCurvePoint(p.temp, p.speed) for p in curve_data.curve_data]), 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, sensor_source=curve_data.sensor_source,
is_active=curve_data.is_active is_active=curve_data.is_active
) )
@ -588,7 +608,7 @@ async def update_fan_curve(
raise HTTPException(status_code=404, detail="Fan curve not found") raise HTTPException(status_code=404, detail="Fan curve not found")
curve.name = curve_data.name curve.name = curve_data.name
curve.curve_data = FanCurveManager.serialize_curve([FanCurvePoint(p.temp, p.speed) for p in curve_data.curve_data]) 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.sensor_source = curve_data.sensor_source
curve.is_active = curve_data.is_active curve.is_active = curve_data.is_active
@ -742,10 +762,10 @@ async def get_server_dashboard(
try: try:
client = IPMIClient( client = IPMIClient(
host=server.host, host=server.ipmi_host,
username=server.username, username=server.ipmi_username,
password=decrypt_password(server.encrypted_password), password=decrypt_password(server.ipmi_encrypted_password),
port=server.port, port=server.ipmi_port,
vendor=server.vendor vendor=server.vendor
) )
@ -799,6 +819,152 @@ async def get_system_logs(
return logs 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) # Health check endpoint (no auth required)
@app.get("/api/health") @app.get("/api/health")
async def health_check(): async def health_check():

View File

@ -5,7 +5,7 @@ pydantic==2.5.3
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
cryptography==42.0.0 bcrypt==4.0.1
python-multipart==0.0.6 python-multipart==0.0.6
aiofiles==23.2.1 aiofiles==23.2.1
httpx==0.26.0 httpx==0.26.0
@ -13,5 +13,5 @@ apscheduler==3.10.4
psutil==5.9.8 psutil==5.9.8
asyncpg==0.29.0 asyncpg==0.29.0
aiosqlite==0.19.0 aiosqlite==0.19.0
pytest==7.4.4 cryptography==42.0.0
pytest-asyncio==0.23.3 asyncssh==2.14.2

View File

@ -62,15 +62,38 @@ class FanCurveResponse(FanCurveBase):
class Config: class Config:
from_attributes = True from_attributes = True
@validator('curve_data', pre=True)
def parse_curve_data(cls, v):
"""Parse curve_data from JSON string if needed."""
if isinstance(v, str):
import json
return json.loads(v)
return v
# IPMI Settings schema
class IPMISettings(BaseModel):
ipmi_host: str = Field(..., description="IPMI IP address or hostname")
ipmi_port: int = Field(623, description="IPMI port (default 623)")
ipmi_username: str = Field(..., description="IPMI username")
ipmi_password: str = Field(..., description="IPMI password")
# SSH Settings schema
class SSHSettings(BaseModel):
use_ssh: bool = Field(False, description="Enable SSH for sensor data collection")
ssh_host: Optional[str] = Field(None, description="SSH host (leave empty to use IPMI host)")
ssh_port: int = Field(22, description="SSH port (default 22)")
ssh_username: Optional[str] = Field(None, description="SSH username")
ssh_password: Optional[str] = Field(None, description="SSH password (or use key)")
ssh_key_file: Optional[str] = Field(None, description="Path to SSH private key file")
# Server schemas # Server schemas
class ServerBase(BaseModel): class ServerBase(BaseModel):
name: str name: str = Field(..., description="Server name/display name")
host: str vendor: str = Field("dell", description="Server vendor (dell, hpe, supermicro, other)")
port: int = 623
username: str
vendor: str = "dell"
@validator('vendor') @validator('vendor')
def validate_vendor(cls, v): def validate_vendor(cls, v):
@ -81,21 +104,22 @@ class ServerBase(BaseModel):
class ServerCreate(ServerBase): class ServerCreate(ServerBase):
password: str ipmi: IPMISettings
ssh: SSHSettings = Field(default_factory=lambda: SSHSettings(use_ssh=False))
@validator('port')
def validate_port(cls, v):
if not 1 <= v <= 65535:
raise ValueError('Port must be between 1 and 65535')
return v
class ServerUpdate(BaseModel): class ServerUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
host: Optional[str] = None ipmi_host: Optional[str] = None
port: Optional[int] = None ipmi_port: Optional[int] = None
username: Optional[str] = None ipmi_username: Optional[str] = None
password: Optional[str] = None ipmi_password: Optional[str] = None
ssh_host: Optional[str] = None
ssh_port: Optional[int] = None
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_key_file: Optional[str] = None
use_ssh: Optional[bool] = None
vendor: Optional[str] = None vendor: Optional[str] = None
manual_control_enabled: Optional[bool] = None manual_control_enabled: Optional[bool] = None
third_party_pcie_response: Optional[bool] = None third_party_pcie_response: Optional[bool] = None
@ -106,6 +130,13 @@ class ServerUpdate(BaseModel):
class ServerResponse(ServerBase): class ServerResponse(ServerBase):
id: int id: int
ipmi_host: str
ipmi_port: int
ipmi_username: str
ssh_host: Optional[str]
ssh_port: int
ssh_username: Optional[str]
use_ssh: bool
manual_control_enabled: bool manual_control_enabled: bool
third_party_pcie_response: bool third_party_pcie_response: bool
auto_control_enabled: bool auto_control_enabled: bool

229
backend/ssh_client.py Normal file
View File

@ -0,0 +1,229 @@
"""SSH client for connecting to servers to get lm-sensors data."""
import asyncio
import json
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
import asyncssh
logger = logging.getLogger(__name__)
@dataclass
class SensorData:
"""Sensor data from lm-sensors."""
name: str
adapter: str
values: Dict[str, float]
unit: str
@dataclass
class CPUTemp:
"""CPU temperature data."""
cpu_name: str
core_temps: Dict[str, float]
package_temp: Optional[float]
class SSHClient:
"""SSH client for server sensor monitoring."""
def __init__(self, host: str, username: str, password: Optional[str] = None,
port: int = 22, key_file: Optional[str] = None):
self.host = host
self.username = username
self.password = password
self.port = port
self.key_file = key_file
self._conn = None
async def connect(self) -> bool:
"""Connect to the server via SSH."""
try:
conn_options = {}
if self.password:
conn_options['password'] = self.password
if self.key_file:
conn_options['client_keys'] = [self.key_file]
self._conn = await asyncssh.connect(
self.host,
port=self.port,
username=self.username,
known_hosts=None, # Allow unknown hosts (use with caution)
**conn_options
)
logger.info(f"SSH connected to {self.host}")
return True
except Exception as e:
logger.error(f"SSH connection failed to {self.host}: {e}")
return False
async def disconnect(self):
"""Disconnect from the server."""
if self._conn:
self._conn.close()
await self._conn.wait_closed()
self._conn = None
async def test_connection(self) -> bool:
"""Test SSH connection."""
if not self._conn:
if not await self.connect():
return False
try:
result = await self._conn.run('echo "test"', check=True)
return result.exit_status == 0
except Exception as e:
logger.error(f"SSH test failed: {e}")
return False
async def get_lmsensors_data(self) -> Optional[Dict[str, Any]]:
"""Get sensor data from lm-sensors."""
if not self._conn:
if not await self.connect():
return None
try:
# Check if sensors command exists
result = await self._conn.run('which sensors', check=False)
if result.exit_status != 0:
logger.warning("lm-sensors not installed on remote server")
return None
# Get sensor data in JSON format
result = await self._conn.run('sensors -j', check=False)
if result.exit_status == 0:
return json.loads(result.stdout)
else:
# Try without JSON format
result = await self._conn.run('sensors', check=False)
if result.exit_status == 0:
return self._parse_sensors_text(result.stdout)
return None
except Exception as e:
logger.error(f"Failed to get lm-sensors data: {e}")
return None
def _parse_sensors_text(self, output: str) -> Dict[str, Any]:
"""Parse plain text sensors output."""
data = {}
current_adapter = None
for line in output.split('\n'):
line = line.strip()
if not line:
continue
# Adapter line
if line.startswith('Adapter:'):
current_adapter = line.replace('Adapter:', '').strip()
continue
# Chip header
if ':' not in line and line:
current_chip = line
if current_chip not in data:
data[current_chip] = {}
if current_adapter:
data[current_chip]['Adapter'] = current_adapter
continue
# Sensor value
if ':' in line and current_chip in data:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value_str = parts[1].strip()
# Try to extract numeric value
try:
# Remove units and extract number
value_clean = ''.join(c for c in value_str if c.isdigit() or c == '.' or c == '-')
if value_clean:
data[current_chip][key] = float(value_clean)
except ValueError:
data[current_chip][key] = value_str
return data
async def get_cpu_temperatures(self) -> List[CPUTemp]:
"""Get CPU temperatures from lm-sensors."""
sensors_data = await self.get_lmsensors_data()
if not sensors_data:
return []
cpu_temps = []
for chip_name, chip_data in sensors_data.items():
# Look for coretemp or k10temp (AMD) chips
if 'coretemp' in chip_name.lower() or 'k10temp' in chip_name.lower():
core_temps = {}
package_temp = None
for key, value in chip_data.items():
if isinstance(value, (int, float)):
if 'core' in key.lower():
core_temps[key] = float(value)
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
package_temp = float(value)
if core_temps or package_temp:
cpu_temps.append(CPUTemp(
cpu_name=chip_name,
core_temps=core_temps,
package_temp=package_temp
))
return cpu_temps
async def get_system_info(self) -> Optional[Dict[str, str]]:
"""Get basic system information."""
if not self._conn:
if not await self.connect():
return None
try:
info = {}
# CPU info
result = await self._conn.run('cat /proc/cpuinfo | grep "model name" | head -1', check=False)
if result.exit_status == 0:
info['cpu'] = result.stdout.split(':')[1].strip() if ':' in result.stdout else 'Unknown'
# Memory info
result = await self._conn.run('free -h | grep Mem', check=False)
if result.exit_status == 0:
parts = result.stdout.split()
if len(parts) >= 2:
info['memory'] = parts[1]
# OS info
result = await self._conn.run('cat /etc/os-release | grep PRETTY_NAME', check=False)
if result.exit_status == 0:
info['os'] = result.stdout.split('=')[1].strip().strip('"')
# Uptime
result = await self._conn.run('uptime -p', check=False)
if result.exit_status == 0:
info['uptime'] = result.stdout.strip()
return info
except Exception as e:
logger.error(f"Failed to get system info: {e}")
return None
async def execute_command(self, command: str) -> tuple[int, str, str]:
"""Execute a custom command on the server."""
if not self._conn:
if not await self.connect():
return -1, "", "Not connected"
try:
result = await self._conn.run(command, check=False)
return result.exit_status, result.stdout, result.stderr
except Exception as e:
logger.error(f"Command execution failed: {e}")
return -1, "", str(e)

View File

@ -24,6 +24,7 @@ import {
MenuItem, MenuItem,
Alert, Alert,
Divider, Divider,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Add as AddIcon,
@ -34,7 +35,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api'; import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api';
import type { FanCurve, FanCurvePoint } from '../types'; import type { FanCurve, FanCurvePoint } from '../types';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
export default function FanCurves() { export default function FanCurves() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -44,6 +45,7 @@ export default function FanCurves() {
const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null); const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null);
const [openDialog, setOpenDialog] = useState(false); const [openDialog, setOpenDialog] = useState(false);
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null); const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null);
const [error, setError] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
sensor_source: 'cpu', sensor_source: 'cpu',
@ -80,6 +82,9 @@ export default function FanCurves() {
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
handleCloseDialog(); handleCloseDialog();
}, },
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to create fan curve');
},
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
@ -89,6 +94,9 @@ export default function FanCurves() {
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
handleCloseDialog(); handleCloseDialog();
}, },
onError: (error: any) => {
setError(error.response?.data?.detail || 'Failed to update fan curve');
},
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@ -117,6 +125,7 @@ export default function FanCurves() {
}); });
const handleOpenDialog = (curve?: FanCurve) => { const handleOpenDialog = (curve?: FanCurve) => {
setError('');
if (curve) { if (curve) {
setEditingCurve(curve); setEditingCurve(curve);
setFormData({ setFormData({
@ -145,11 +154,37 @@ export default function FanCurves() {
const handleCloseDialog = () => { const handleCloseDialog = () => {
setOpenDialog(false); setOpenDialog(false);
setEditingCurve(null); setEditingCurve(null);
setError('');
}; };
const handleSubmit = () => { const handleSubmit = () => {
setError('');
// Validation
if (!formData.name.trim()) {
setError('Curve name is required');
return;
}
if (formData.points.length < 2) {
setError('At least 2 points are required');
return;
}
// Validate points
for (const point of formData.points) {
if (point.speed < 0 || point.speed > 100) {
setError('Fan speed must be between 0 and 100');
return;
}
if (point.temp < 0 || point.temp > 150) {
setError('Temperature must be between 0 and 150');
return;
}
}
const data = { const data = {
name: formData.name, name: formData.name.trim(),
curve_data: formData.points, curve_data: formData.points,
sensor_source: formData.sensor_source, sensor_source: formData.sensor_source,
is_active: true, is_active: true,
@ -184,6 +219,8 @@ export default function FanCurves() {
} }
}; };
const isLoading = createMutation.isPending || updateMutation.isPending;
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
@ -324,13 +361,6 @@ export default function FanCurves() {
name === 'speed' ? 'Fan Speed' : 'Temperature', name === 'speed' ? 'Fan Speed' : 'Temperature',
]} ]}
/> />
<Area
type="monotone"
dataKey="speed"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.3}
/>
<Line <Line
type="monotone" type="monotone"
dataKey="speed" dataKey="speed"
@ -366,12 +396,21 @@ export default function FanCurves() {
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'} {editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField <TextField
fullWidth fullWidth
label="Name" label="Curve Name"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal" margin="normal"
required
error={!formData.name && !!error}
helperText={!formData.name && error ? 'Name is required' : 'Enter a name for this fan curve'}
/> />
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel>Sensor Source</InputLabel> <InputLabel>Sensor Source</InputLabel>
@ -405,6 +444,7 @@ export default function FanCurves() {
updatePoint(index, 'temp', parseInt(e.target.value) || 0) updatePoint(index, 'temp', parseInt(e.target.value) || 0)
} }
sx={{ flex: 1 }} sx={{ flex: 1 }}
inputProps={{ min: 0, max: 150 }}
/> />
<TextField <TextField
type="number" type="number"
@ -460,17 +500,16 @@ export default function FanCurves() {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button> <Button onClick={handleCloseDialog} disabled={isLoading}>
Cancel
</Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
variant="contained" variant="contained"
disabled={ disabled={isLoading || !formData.name.trim()}
!formData.name || startIcon={isLoading ? <CircularProgress size={20} /> : null}
createMutation.isPending ||
updateMutation.isPending
}
> >
{editingCurve ? 'Update' : 'Create'} {isLoading ? 'Saving...' : editingCurve ? 'Update' : 'Create'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -24,16 +24,17 @@ import {
TableRow, TableRow,
Switch, Switch,
FormControlLabel, FormControlLabel,
Tooltip,
IconButton,
} from '@mui/material'; } from '@mui/material';
import { import {
Speed as SpeedIcon, Speed as SpeedIcon,
Thermostat as TempIcon, Thermostat as TempIcon,
Power as PowerIcon, Power as PowerIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { serversApi, fanControlApi, dashboardApi } from '../utils/api'; import { serversApi, fanControlApi, dashboardApi } from '../utils/api';
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
index: number; index: number;
@ -65,7 +66,7 @@ export default function ServerDetail() {
}, },
}); });
const { data: sensors } = useQuery({ const { data: sensors, refetch: refetchSensors } = useQuery({
queryKey: ['sensors', serverId], queryKey: ['sensors', serverId],
queryFn: async () => { queryFn: async () => {
const response = await serversApi.getSensors(serverId); const response = await serversApi.getSensors(serverId);
@ -74,6 +75,22 @@ export default function ServerDetail() {
refetchInterval: 5000, refetchInterval: 5000,
}); });
// Get SSH sensors for core temps - use dedicated endpoint
const { data: sshSensors, isLoading: isSSHSensorsLoading } = useQuery({
queryKey: ['ssh-sensors', serverId],
queryFn: async () => {
if (!server?.use_ssh) return null;
try {
const response = await serversApi.getSSHSensors(serverId);
return response.data;
} catch {
return null;
}
},
enabled: !!server?.use_ssh,
refetchInterval: 10000, // Slower refresh for SSH
});
const { data: dashboardData } = useQuery({ const { data: dashboardData } = useQuery({
queryKey: ['dashboard-server', serverId], queryKey: ['dashboard-server', serverId],
queryFn: async () => { queryFn: async () => {
@ -123,7 +140,14 @@ export default function ServerDetail() {
} }
const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || []; const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || [];
// Format SSH CPU temps for display
const sshCPUTemps = sshSensors?.cpu_temps || [];
// Get detected fans from IPMI sensors, sorted by fan number
const detectedFans = sensors?.fans
? [...sensors.fans].sort((a, b) => a.fan_number - b.fan_number)
: [];
return ( return (
<Box> <Box>
@ -131,7 +155,8 @@ export default function ServerDetail() {
<Box> <Box>
<Typography variant="h4">{server.name}</Typography> <Typography variant="h4">{server.name}</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{server.host}:{server.port} {server.vendor.toUpperCase()} IPMI: {server.ipmi_host}:{server.ipmi_port} {server.vendor.toUpperCase()}
{server.use_ssh && ` • SSH: ${server.ssh_host || server.ipmi_host}`}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
@ -143,6 +168,9 @@ export default function ServerDetail() {
{server.auto_control_enabled && ( {server.auto_control_enabled && (
<Chip color="success" label="Auto Curve" /> <Chip color="success" label="Auto Curve" />
)} )}
{server.use_ssh && (
<Chip color="info" label="SSH Enabled" />
)}
</Box> </Box>
</Box> </Box>
@ -156,42 +184,79 @@ export default function ServerDetail() {
{/* Overview Tab */} {/* Overview Tab */}
<TabPanel value={tabValue} index={0}> <TabPanel value={tabValue} index={0}>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* CPU Temps from SSH (preferred) or IPMI */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
<TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} /> <TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
CPU Temperatures CPU Core Temperatures
</Typography> </Typography>
<Grid container spacing={2}>
{cpuTemps.map((temp) => ( {isSSHSensorsLoading ? (
<Grid item xs={6} key={temp.name}> <Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}> <CircularProgress size={24} />
<Typography variant="h4">{temp.value.toFixed(1)}°C</Typography> <Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
<Typography variant="body2" color="text.secondary"> Loading SSH sensors...
{temp.name} </Typography>
</Box>
) : sshCPUTemps.length > 0 ? (
<>
{sshCPUTemps.map((cpu: any, idx: number) => (
<Box key={idx} sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{cpu.cpu_name || `CPU ${idx + 1}`}
{cpu.package_temp && ` (Package: ${cpu.package_temp}°C)`}
</Typography> </Typography>
<Chip <Grid container spacing={1}>
size="small" {Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
label={temp.status} <Grid item xs={6} sm={4} key={coreName}>
color={temp.status === 'ok' ? 'success' : 'error'} <Paper
sx={{ mt: 1 }} variant="outlined"
/> sx={{ p: 1.5, textAlign: 'center' }}
</Paper> >
</Grid> <Typography variant="h6" color="success">
))} {temp as number}°C
{cpuTemps.length === 0 && ( </Typography>
<Grid item xs={12}> <Typography variant="body2" color="text.secondary">
<Typography color="text.secondary" align="center"> {coreName}
No CPU temperature data available </Typography>
</Typography> </Paper>
</Grid> </Grid>
)} ))}
</Grid> </Grid>
</Box>
))}
</>
) : cpuTemps.length > 0 ? (
<Grid container spacing={2}>
{cpuTemps.map((temp) => (
<Grid item xs={6} key={temp.name}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4">{temp.value.toFixed(1)}°C</Typography>
<Typography variant="body2" color="text.secondary">
{temp.name}
</Typography>
<Chip
size="small"
label={temp.status}
color={temp.status === 'ok' ? 'success' : 'error'}
sx={{ mt: 1 }}
/>
</Paper>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">
No CPU temperature data available. Enable SSH for detailed core temperatures.
</Alert>
)}
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
{/* Fan Speeds */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>
<CardContent> <CardContent>
@ -231,6 +296,7 @@ export default function ServerDetail() {
</Card> </Card>
</Grid> </Grid>
{/* Power Consumption */}
{dashboardData?.power_consumption && ( {dashboardData?.power_consumption && (
<Grid item xs={12}> <Grid item xs={12}>
<Card> <Card>
@ -240,16 +306,35 @@ export default function ServerDetail() {
Power Consumption Power Consumption
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
{Object.entries(dashboardData.power_consumption).map(([key, value]) => ( {Object.entries(dashboardData.power_consumption)
<Grid item xs={6} md={3} key={key}> .filter(([_, value]) => !value.includes('UTC')) // Filter out weird timestamp entries
<Paper variant="outlined" sx={{ p: 2 }}> .slice(0, 4)
<Typography variant="body2" color="text.secondary"> .map(([key, value]) => {
{key} // Clean up the display
</Typography> let displayValue = value as string;
<Typography variant="h6">{value}</Typography> let displayKey = key;
</Paper>
</Grid> // Handle Dell power monitor output
))} if (key.includes('System') && value.includes('Reading')) {
const match = value.match(/Reading\s*:\s*([\d.]+)\s*(\w+)/);
if (match) {
displayValue = `${match[1]} ${match[2]}`;
}
}
return (
<Grid item xs={6} md={3} key={key}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
{displayKey.replace(/_/g, ' ')}
</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>
{displayValue}
</Typography>
</Paper>
</Grid>
);
})}
</Grid> </Grid>
</CardContent> </CardContent>
</Card> </Card>
@ -265,8 +350,13 @@ export default function ServerDetail() {
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Control Mode Manual Fan Control
</Typography> </Typography>
<Alert severity="info" sx={{ mb: 2 }}>
Enable manual control to set fan speeds. When disabled, the server uses automatic fan control.
</Alert>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<FormControlLabel <FormControlLabel
control={ control={
@ -281,7 +371,7 @@ export default function ServerDetail() {
}} }}
/> />
} }
label="Manual Fan Control" label="Enable Manual Fan Control"
/> />
</Box> </Box>
@ -291,54 +381,75 @@ export default function ServerDetail() {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Set Fan Speed Set Fan Speed
</Typography> </Typography>
<Box sx={{ px: 2, py: 2 }}>
{detectedFans.length === 0 && (
<Alert severity="warning" sx={{ mb: 2 }}>
No fans detected via IPMI. Please check your server connection.
</Alert>
)}
<Box sx={{ mb: 2 }}>
<Typography gutterBottom> <Typography gutterBottom>
Fan: {selectedFan === '0xff' ? 'All Fans' : `Fan ${parseInt(selectedFan, 16) + 1}`} Target: {selectedFan === '0xff' ? 'All Fans' :
detectedFans.find(f => f.fan_id === selectedFan)
? `Fan ${detectedFans.find(f => f.fan_id === selectedFan)?.fan_number}`
: 'Unknown Fan'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<Button
variant={selectedFan === '0xff' ? 'contained' : 'outlined'}
size="small"
onClick={() => setSelectedFan('0xff')}
disabled={detectedFans.length === 0}
>
All Fans
</Button>
{detectedFans.map((fan) => (
<Button
key={fan.fan_id}
variant={selectedFan === fan.fan_id ? 'contained' : 'outlined'}
size="small"
onClick={() => setSelectedFan(fan.fan_id)}
title={`Fan ${fan.fan_number} - ${fan.speed_rpm ? fan.speed_rpm + ' RPM' : 'No RPM data'}`}
>
Fan {fan.fan_number}
</Button>
))}
</Box>
</Box>
<Box sx={{ px: 2, py: 2 }}>
<Typography gutterBottom color={detectedFans.length === 0 ? 'text.secondary' : 'inherit'}>
Speed: {fanSpeed}%
</Typography> </Typography>
<Slider <Slider
value={fanSpeed} value={fanSpeed}
onChange={handleFanSpeedChange} onChange={handleFanSpeedChange}
min={0} min={10}
max={100} max={100}
step={1} step={1}
marks={[ marks={[
{ value: 0, label: '0%' }, { value: 10, label: '10%' },
{ value: 50, label: '50%' }, { value: 50, label: '50%' },
{ value: 100, label: '100%' }, { value: 100, label: '100%' },
]} ]}
valueLabelDisplay="auto" valueLabelDisplay="auto"
valueLabelFormat={(v) => `${v}%`} valueLabelFormat={(v) => `${v}%`}
disabled={detectedFans.length === 0}
/> />
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
onClick={() => setSelectedFan('0xff')}
color={selectedFan === '0xff' ? 'primary' : 'inherit'}
>
All Fans
</Button>
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<Button
key={i}
variant="outlined"
size="small"
onClick={() => setSelectedFan(`0x0${i}`)}
color={selectedFan === `0x0${i}` ? 'primary' : 'inherit'}
>
{i + 1}
</Button>
))}
</Box>
<Button
variant="contained"
fullWidth
sx={{ mt: 2 }}
onClick={handleApplyFanSpeed}
disabled={setFanSpeedMutation.isPending}
>
Apply {fanSpeed}% Speed
</Button>
</Box> </Box>
<Button
variant="contained"
fullWidth
sx={{ mt: 2 }}
onClick={handleApplyFanSpeed}
disabled={setFanSpeedMutation.isPending || detectedFans.length === 0}
startIcon={<SpeedIcon />}
>
Apply {fanSpeed}% Speed
</Button>
</> </>
)} )}
</CardContent> </CardContent>
@ -356,15 +467,19 @@ export default function ServerDetail() {
control={<Switch checked={server.panic_mode_enabled} disabled />} control={<Switch checked={server.panic_mode_enabled} disabled />}
label="Panic Mode (Auto 100% on sensor loss)" label="Panic Mode (Auto 100% on sensor loss)"
/> />
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Timeout: {server.panic_timeout_seconds} seconds
</Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Divider sx={{ my: 2 }} />
Timeout: {server.panic_timeout_seconds} seconds <Box>
</Typography>
<Box sx={{ mt: 2 }}>
<FormControlLabel <FormControlLabel
control={<Switch checked={server.third_party_pcie_response} disabled />} control={<Switch checked={server.third_party_pcie_response} disabled />}
label="3rd Party PCIe Card Response" label="3rd Party PCIe Card Response"
/> />
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Controls fan response when using non-Dell PCIe cards
</Typography>
</Box> </Box>
</CardContent> </CardContent>
</Card> </Card>
@ -372,15 +487,106 @@ export default function ServerDetail() {
</Grid> </Grid>
</TabPanel> </TabPanel>
{/* Sensors Tab */} {/* Sensors Tab - Merged IPMI and SSH */}
<TabPanel value={tabValue} index={2}> <TabPanel value={tabValue} index={2}>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* SSH Core Temps */}
{isSSHSensorsLoading ? (
<Grid item xs={12}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<CircularProgress size={24} sx={{ mr: 1 }} />
<Typography color="text.secondary">Loading SSH sensors...</Typography>
</Box>
</CardContent>
</Card>
</Grid>
) : sshCPUTemps.length > 0 && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
CPU Core Temperatures (lm-sensors via SSH)
</Typography>
<Grid container spacing={3}>
{sshCPUTemps.map((cpu: any, idx: number) => (
<Grid item xs={12} md={6} key={idx}>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
{cpu.cpu_name || `CPU ${idx + 1}`}
{cpu.package_temp && (
<Chip
size="small"
label={`Package: ${cpu.package_temp}°C`}
color="primary"
sx={{ ml: 1 }}
/>
)}
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Core</TableCell>
<TableCell align="right">Temp</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
<TableRow key={coreName}>
<TableCell>{coreName}</TableCell>
<TableCell align="right">
<Chip
label={`${temp as number}°C`}
color="success"
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
))}
</Grid>
</CardContent>
</Card>
</Grid>
)}
{/* Raw SSH Sensors Data (if available) */}
{sshSensors?.raw_data && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
Raw lm-sensors Data
</Typography>
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
<pre style={{ margin: 0, fontSize: '0.875rem' }}>
{JSON.stringify(sshSensors.raw_data, null, 2)}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* IPMI Temperatures */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
All Temperature Sensors <Typography variant="h6">IPMI Temperature Sensors</Typography>
</Typography> <Tooltip title="Refresh">
<IconButton onClick={() => refetchSensors()}>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
<TableContainer> <TableContainer>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@ -413,14 +619,15 @@ export default function ServerDetail() {
</Card> </Card>
</Grid> </Grid>
{/* IPMI All Sensors */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
All Sensors All IPMI Sensors
</Typography> </Typography>
<TableContainer> <TableContainer sx={{ maxHeight: 400 }}>
<Table size="small"> <Table size="small" stickyHeader>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Sensor</TableCell> <TableCell>Sensor</TableCell>
@ -429,7 +636,7 @@ export default function ServerDetail() {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{sensors?.all_sensors.slice(0, 20).map((sensor) => ( {sensors?.all_sensors.slice(0, 50).map((sensor) => (
<TableRow key={sensor.name}> <TableRow key={sensor.name}>
<TableCell>{sensor.name}</TableCell> <TableCell>{sensor.name}</TableCell>
<TableCell>{sensor.sensor_type}</TableCell> <TableCell>{sensor.sensor_type}</TableCell>
@ -457,16 +664,46 @@ export default function ServerDetail() {
</Typography> </Typography>
{dashboardData?.power_consumption ? ( {dashboardData?.power_consumption ? (
<Grid container spacing={3}> <Grid container spacing={3}>
{Object.entries(dashboardData.power_consumption).map(([key, value]) => ( {Object.entries(dashboardData.power_consumption)
<Grid item xs={12} md={4} key={key}> .filter(([_key, value]) => {
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}> // Filter out entries that look like timestamps or are too messy
<Typography variant="h5">{value}</Typography> const valueStr = String(value);
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> return !valueStr.includes('UTC') &&
{key} !valueStr.includes('Peak Time') &&
</Typography> !valueStr.includes('Statistic') &&
</Paper> valueStr.length < 50;
</Grid> })
))} .map(([key, value]) => {
let displayValue = String(value);
let displayKey = key;
// Clean up Dell power monitor output
if (value.includes('Reading')) {
const match = value.match(/Reading\s*:\s*([\d.]+)\s*(\w+)/);
if (match) {
displayValue = `${match[1]} ${match[2]}`;
}
}
if (value.includes('Statistic')) {
const match = value.match(/Statistic\s*:\s*(.+)/);
if (match) {
displayValue = match[1];
}
}
return (
<Grid item xs={12} md={4} key={key}>
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
{displayKey.replace(/_/g, ' ')}
</Typography>
<Typography variant="h5" sx={{ mt: 1 }}>
{displayValue}
</Typography>
</Paper>
</Grid>
);
})}
</Grid> </Grid>
) : ( ) : (
<Alert severity="info"> <Alert severity="info">

View File

@ -26,29 +26,47 @@ import {
Alert, Alert,
CircularProgress, CircularProgress,
Tooltip, Tooltip,
Divider,
Switch,
FormControlLabel,
Stepper,
Step,
StepLabel,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Add as AddIcon,
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Settings as SettingsIcon,
Speed as SpeedIcon, Speed as SpeedIcon,
Computer as ComputerIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { serversApi } from '../utils/api'; import { serversApi } from '../utils/api';
import type { Server } from '../types'; import type { Server } from '../types';
const STEPS = ['IPMI Connection', 'SSH Connection (Optional)', 'Review'];
export default function ServerList() { export default function ServerList() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [openDialog, setOpenDialog] = useState(false); const [openDialog, setOpenDialog] = useState(false);
const [editingServer, setEditingServer] = useState<Server | null>(null); const [editingServer, setEditingServer] = useState<Server | null>(null);
const [activeStep, setActiveStep] = useState(0);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
// Basic
name: '', name: '',
host: '',
port: 623,
username: '',
password: '',
vendor: 'dell', vendor: 'dell',
// IPMI
ipmi_host: '',
ipmi_port: 623,
ipmi_username: '',
ipmi_password: '',
// SSH
use_ssh: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
}); });
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
@ -95,21 +113,32 @@ export default function ServerList() {
setEditingServer(server); setEditingServer(server);
setFormData({ setFormData({
name: server.name, name: server.name,
host: server.host,
port: server.port,
username: server.username,
password: '', // Don't show password
vendor: server.vendor, vendor: server.vendor,
ipmi_host: server.ipmi_host,
ipmi_port: server.ipmi_port,
ipmi_username: server.ipmi_username,
ipmi_password: '',
use_ssh: server.use_ssh,
ssh_host: server.ssh_host || '',
ssh_port: server.ssh_port,
ssh_username: server.ssh_username || '',
ssh_password: '',
}); });
} else { } else {
setEditingServer(null); setEditingServer(null);
setActiveStep(0);
setFormData({ setFormData({
name: '', name: '',
host: '',
port: 623,
username: '',
password: '',
vendor: 'dell', vendor: 'dell',
ipmi_host: '',
ipmi_port: 623,
ipmi_username: '',
ipmi_password: '',
use_ssh: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
}); });
} }
setFormError(''); setFormError('');
@ -119,34 +148,278 @@ export default function ServerList() {
const handleCloseDialog = () => { const handleCloseDialog = () => {
setOpenDialog(false); setOpenDialog(false);
setEditingServer(null); setEditingServer(null);
setActiveStep(0);
setFormError(''); setFormError('');
}; };
const handleSubmit = () => { const validateStep = (step: number): boolean => {
if (!formData.name || !formData.host || !formData.username) { if (step === 0) {
setFormError('Please fill in all required fields'); // Validate IPMI fields
return; if (!formData.name.trim()) {
setFormError('Server name is required');
return false;
}
if (!formData.ipmi_host.trim()) {
setFormError('IPMI IP address/hostname is required');
return false;
}
if (!formData.ipmi_username.trim()) {
setFormError('IPMI username is required');
return false;
}
if (!editingServer && !formData.ipmi_password) {
setFormError('IPMI password is required');
return false;
}
} }
setFormError('');
return true;
};
if (!editingServer && !formData.password) { const handleNext = () => {
setFormError('Password is required for new servers'); if (validateStep(activeStep)) {
return; setActiveStep((prev) => prev + 1);
} }
};
const handleBack = () => {
setFormError('');
setActiveStep((prev) => prev - 1);
};
const handleSubmit = () => {
const data = {
name: formData.name,
vendor: formData.vendor,
ipmi: {
ipmi_host: formData.ipmi_host,
ipmi_port: formData.ipmi_port,
ipmi_username: formData.ipmi_username,
ipmi_password: formData.ipmi_password,
},
ssh: {
use_ssh: formData.use_ssh,
ssh_host: formData.ssh_host || formData.ipmi_host,
ssh_port: formData.ssh_port,
ssh_username: formData.ssh_username || formData.ipmi_username,
ssh_password: formData.ssh_password,
},
};
if (editingServer) { if (editingServer) {
const updateData: any = { const updateData: any = {
name: formData.name, name: data.name,
host: formData.host, vendor: data.vendor,
port: formData.port, ipmi_host: data.ipmi.ipmi_host,
username: formData.username, ipmi_port: data.ipmi.ipmi_port,
vendor: formData.vendor, ipmi_username: data.ipmi.ipmi_username,
use_ssh: data.ssh.use_ssh,
ssh_host: data.ssh.ssh_host,
ssh_port: data.ssh.ssh_port,
ssh_username: data.ssh.ssh_username,
}; };
if (formData.password) { if (data.ipmi.ipmi_password) {
updateData.password = formData.password; updateData.ipmi_password = data.ipmi.ipmi_password;
}
if (data.ssh.ssh_password) {
updateData.ssh_password = data.ssh.ssh_password;
} }
updateMutation.mutate({ id: editingServer.id, data: updateData }); updateMutation.mutate({ id: editingServer.id, data: updateData });
} else { } else {
createMutation.mutate(formData as any); createMutation.mutate(data);
}
};
const renderStepContent = (step: number) => {
switch (step) {
case 0:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom color="primary">
<ComputerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
IPMI Connection (Required)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Enter the IPMI/iDRAC/iLO credentials for fan control and basic sensor reading.
</Typography>
<TextField
fullWidth
label="Server Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
required
helperText="A friendly name for this server"
/>
<FormControl fullWidth margin="normal">
<InputLabel>Server Vendor</InputLabel>
<Select
value={formData.vendor}
label="Server Vendor"
onChange={(e) => setFormData({ ...formData, vendor: e.target.value })}
>
<MenuItem value="dell">Dell (iDRAC)</MenuItem>
<MenuItem value="hpe">HPE (iLO)</MenuItem>
<MenuItem value="supermicro">Supermicro</MenuItem>
<MenuItem value="other">Other</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="IPMI IP Address / Hostname"
value={formData.ipmi_host}
onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })}
margin="normal"
required
placeholder="192.168.1.100"
helperText="The IP address of your iDRAC/iLO/IPMI interface"
/>
<TextField
fullWidth
type="number"
label="IPMI Port"
value={formData.ipmi_port}
onChange={(e) => setFormData({ ...formData, ipmi_port: parseInt(e.target.value) || 623 })}
margin="normal"
helperText="Default is 623 for most servers"
/>
<TextField
fullWidth
label="IPMI Username"
value={formData.ipmi_username}
onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })}
margin="normal"
required
placeholder="root"
/>
<TextField
fullWidth
type="password"
label={editingServer ? 'IPMI Password (leave blank to keep current)' : 'IPMI Password'}
value={formData.ipmi_password}
onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })}
margin="normal"
required={!editingServer}
/>
</Box>
);
case 1:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom color="primary">
SSH Connection (Optional)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
SSH provides more detailed sensor data via lm-sensors (CPU core temperatures, etc.).
If not configured, only IPMI sensors will be used.
</Typography>
<FormControlLabel
control={
<Switch
checked={formData.use_ssh}
onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })}
/>
}
label="Enable SSH for detailed sensor data"
/>
{formData.use_ssh && (
<>
<Divider sx={{ my: 2 }} />
<TextField
fullWidth
label="SSH Host"
value={formData.ssh_host}
onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })}
margin="normal"
placeholder={formData.ipmi_host}
helperText="Leave empty to use IPMI host"
/>
<TextField
fullWidth
type="number"
label="SSH Port"
value={formData.ssh_port}
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
margin="normal"
/>
<TextField
fullWidth
label="SSH Username"
value={formData.ssh_username}
onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })}
margin="normal"
placeholder={formData.ipmi_username}
helperText="Leave empty to use IPMI username"
/>
<TextField
fullWidth
type="password"
label={editingServer ? 'SSH Password (leave blank to keep current)' : 'SSH Password'}
value={formData.ssh_password}
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
margin="normal"
helperText="Password for SSH authentication"
/>
</>
)}
</Box>
);
case 2:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Review Configuration
</Typography>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
Basic Info
</Typography>
<Typography><strong>Name:</strong> {formData.name}</Typography>
<Typography><strong>Vendor:</strong> {formData.vendor.toUpperCase()}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
IPMI Connection
</Typography>
<Typography><strong>Host:</strong> {formData.ipmi_host}:{formData.ipmi_port}</Typography>
<Typography><strong>Username:</strong> {formData.ipmi_username}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
SSH Connection
</Typography>
<Typography>
<strong>Status:</strong> {formData.use_ssh ? 'Enabled' : 'Disabled'}
</Typography>
{formData.use_ssh && (
<>
<Typography><strong>Host:</strong> {formData.ssh_host || formData.ipmi_host}:{formData.ssh_port}</Typography>
<Typography><strong>Username:</strong> {formData.ssh_username || formData.ipmi_username}</Typography>
</>
)}
</Paper>
</Box>
);
default:
return null;
} }
}; };
@ -186,8 +459,8 @@ export default function ServerList() {
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell>Host</TableCell> <TableCell>IPMI Host</TableCell>
<TableCell>Vendor</TableCell> <TableCell>SSH</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Last Seen</TableCell> <TableCell>Last Seen</TableCell>
<TableCell align="right">Actions</TableCell> <TableCell align="right">Actions</TableCell>
@ -195,39 +468,55 @@ export default function ServerList() {
</TableHead> </TableHead>
<TableBody> <TableBody>
{servers?.map((server) => ( {servers?.map((server) => (
<TableRow key={server.id} hover> <TableRow
<TableCell>{server.name}</TableCell> key={server.id}
<TableCell>{server.host}</TableCell> hover
<TableCell>{server.vendor.toUpperCase()}</TableCell> onClick={() => navigate(`/servers/${server.id}`)}
sx={{ cursor: 'pointer' }}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{server.name}
<Chip size="small" label={server.vendor.toUpperCase()} variant="outlined" />
</Box>
</TableCell>
<TableCell>{server.ipmi_host}</TableCell>
<TableCell>
{server.use_ssh ? (
<Chip size="small" color="success" label="Enabled" />
) : (
<Chip size="small" color="default" label="Disabled" />
)}
</TableCell>
<TableCell>{getStatusChip(server)}</TableCell> <TableCell>{getStatusChip(server)}</TableCell>
<TableCell> <TableCell>
{server.last_seen {server.last_seen
? new Date(server.last_seen).toLocaleString() ? new Date(server.last_seen).toLocaleString()
: 'Never'} : 'Never'}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title="Fan Curves"> <Tooltip title="Fan Curves">
<IconButton <IconButton
onClick={() => navigate(`/servers/${server.id}/curves`)} onClick={(e) => {
e.stopPropagation();
navigate(`/servers/${server.id}/curves`);
}}
> >
<SpeedIcon /> <SpeedIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Settings">
<IconButton
onClick={() => navigate(`/servers/${server.id}`)}
>
<SettingsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit"> <Tooltip title="Edit">
<IconButton onClick={() => handleOpenDialog(server)}> <IconButton onClick={(e) => {
e.stopPropagation();
handleOpenDialog(server);
}}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton <IconButton
onClick={() => { onClick={(e) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this server?')) { if (confirm('Are you sure you want to delete this server?')) {
deleteMutation.mutate(server.id); deleteMutation.mutate(server.id);
} }
@ -254,7 +543,13 @@ export default function ServerList() {
</TableContainer> </TableContainer>
{/* Add/Edit Dialog */} {/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth> <Dialog
open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
fullScreen={false}
>
<DialogTitle> <DialogTitle>
{editingServer ? 'Edit Server' : 'Add Server'} {editingServer ? 'Edit Server' : 'Add Server'}
</DialogTitle> </DialogTitle>
@ -264,84 +559,62 @@ export default function ServerList() {
{formError} {formError}
</Alert> </Alert>
)} )}
<TextField
fullWidth {!editingServer && (
label="Name" <Stepper activeStep={activeStep} sx={{ mb: 2 }}>
value={formData.name} {STEPS.map((label) => (
onChange={(e) => setFormData({ ...formData, name: e.target.value })} <Step key={label}>
margin="normal" <StepLabel>{label}</StepLabel>
required </Step>
/> ))}
<TextField </Stepper>
fullWidth )}
label="IP Address / Hostname"
value={formData.host} {editingServer ? (
onChange={(e) => setFormData({ ...formData, host: e.target.value })} // Editing mode - show all fields at once
margin="normal" <Box>
required <Typography variant="h6" gutterBottom>Basic Info</Typography>
/> <TextField fullWidth label="Name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} margin="normal" />
<TextField
fullWidth <Typography variant="h6" gutterBottom sx={{ mt: 2 }}>IPMI</Typography>
type="number" <TextField fullWidth label="IPMI Host" value={formData.ipmi_host} onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })} margin="normal" />
label="Port" <TextField fullWidth label="IPMI Username" value={formData.ipmi_username} onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })} margin="normal" />
value={formData.port} <TextField fullWidth type="password" label="IPMI Password (leave blank to keep)" value={formData.ipmi_password} onChange={(e) => setFormData({ ...formData, ipmi_password: e.target.value })} margin="normal" />
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) || 623 }) <Typography variant="h6" gutterBottom sx={{ mt: 2 }}>SSH</Typography>
} <FormControlLabel control={<Switch checked={formData.use_ssh} onChange={(e) => setFormData({ ...formData, use_ssh: e.target.checked })} />} label="Enable SSH" />
margin="normal" {formData.use_ssh && (
/> <>
<TextField <TextField fullWidth label="SSH Host" value={formData.ssh_host} onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })} margin="normal" />
fullWidth <TextField fullWidth label="SSH Username" value={formData.ssh_username} onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })} margin="normal" />
label="Username" <TextField fullWidth type="password" label="SSH Password (leave blank to keep)" value={formData.ssh_password} onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })} margin="normal" />
value={formData.username} </>
onChange={(e) => )}
setFormData({ ...formData, username: e.target.value }) </Box>
} ) : (
margin="normal" // Creating mode - show stepper
required renderStepContent(activeStep)
/> )}
<TextField
fullWidth
type="password"
label={editingServer ? 'Password (leave blank to keep current)' : 'Password'}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
margin="normal"
required={!editingServer}
/>
<FormControl fullWidth margin="normal">
<InputLabel>Vendor</InputLabel>
<Select
value={formData.vendor}
label="Vendor"
onChange={(e) =>
setFormData({ ...formData, vendor: e.target.value })
}
>
<MenuItem value="dell">Dell</MenuItem>
<MenuItem value="hpe">HPE</MenuItem>
<MenuItem value="supermicro">Supermicro</MenuItem>
<MenuItem value="other">Other</MenuItem>
</Select>
</FormControl>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button> <Button onClick={handleCloseDialog}>Cancel</Button>
<Button
onClick={handleSubmit} {editingServer ? (
variant="contained" <Button onClick={handleSubmit} variant="contained" disabled={updateMutation.isPending}>
disabled={createMutation.isPending || updateMutation.isPending} {updateMutation.isPending ? <CircularProgress size={24} /> : 'Update'}
> </Button>
{createMutation.isPending || updateMutation.isPending ? ( ) : (
<CircularProgress size={24} /> <>
) : editingServer ? ( <Button onClick={handleBack} disabled={activeStep === 0}>Back</Button>
'Update' {activeStep === STEPS.length - 1 ? (
) : ( <Button onClick={handleSubmit} variant="contained" disabled={createMutation.isPending}>
'Add' {createMutation.isPending ? <CircularProgress size={24} /> : 'Create'}
)} </Button>
</Button> ) : (
<Button onClick={handleNext} variant="contained">Next</Button>
)}
</>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>

View File

@ -6,12 +6,37 @@ export interface User {
last_login: string | null; last_login: string | null;
} }
// IPMI Settings
export interface IPMISettings {
ipmi_host: string;
ipmi_port: number;
ipmi_username: string;
ipmi_password: string;
}
// SSH Settings
export interface SSHSettings {
use_ssh: boolean;
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
ssh_password?: string;
ssh_key_file?: string;
}
export interface Server { export interface Server {
id: number; id: number;
name: string; name: string;
host: string; // IPMI
port: number; ipmi_host: string;
username: string; ipmi_port: number;
ipmi_username: string;
// SSH
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
use_ssh: boolean;
// Other
vendor: string; vendor: string;
manual_control_enabled: boolean; manual_control_enabled: boolean;
third_party_pcie_response: boolean; third_party_pcie_response: boolean;
@ -107,3 +132,20 @@ export interface AutoControlSettings {
enabled: boolean; enabled: boolean;
curve_id?: number; curve_id?: number;
} }
// SSH Sensor Data
export interface SSHSensorData {
cpu_temps: {
cpu_name: string;
core_temps: Record<string, number>;
package_temp: number | null;
}[];
raw_data: Record<string, any>;
}
export interface SystemInfo {
cpu?: string;
memory?: string;
os?: string;
uptime?: string;
}

View File

@ -13,6 +13,8 @@ import type {
ServerSensors, ServerSensors,
FanControlCommand, FanControlCommand,
AutoControlSettings, AutoControlSettings,
SSHSensorData,
SystemInfo,
} from '../types'; } from '../types';
const API_URL = import.meta.env.VITE_API_URL || ''; const API_URL = import.meta.env.VITE_API_URL || '';
@ -72,18 +74,33 @@ export const serversApi = {
getById: (id: number) => api.get<Server>(`/servers/${id}`), getById: (id: number) => api.get<Server>(`/servers/${id}`),
create: (data: { create: (data: {
name: string; name: string;
host: string;
port: number;
username: string;
password: string;
vendor: string; vendor: string;
ipmi: {
ipmi_host: string;
ipmi_port: number;
ipmi_username: string;
ipmi_password: string;
};
ssh: {
use_ssh: boolean;
ssh_host?: string;
ssh_port: number;
ssh_username?: string;
ssh_password?: string;
ssh_key_file?: string;
};
}) => api.post<Server>('/servers', data), }) => api.post<Server>('/servers', data),
update: (id: number, data: Partial<Server> & { password?: string }) => update: (id: number, data: any) =>
api.put<Server>(`/servers/${id}`, data), api.put<Server>(`/servers/${id}`, data),
delete: (id: number) => api.delete(`/servers/${id}`), delete: (id: number) => api.delete(`/servers/${id}`),
getStatus: (id: number) => api.get<ServerStatus>(`/servers/${id}/status`), getStatus: (id: number) => api.get<ServerStatus>(`/servers/${id}/status`),
getSensors: (id: number) => api.get<ServerSensors>(`/servers/${id}/sensors`), getSensors: (id: number) => api.get<ServerSensors>(`/servers/${id}/sensors`),
getPower: (id: number) => api.get<Record<string, string>>(`/servers/${id}/power`), getPower: (id: number) => api.get<Record<string, string>>(`/servers/${id}/power`),
// SSH endpoints
getSSHSensors: (id: number) => api.get<SSHSensorData>(`/servers/${id}/ssh/sensors`),
getSSHSystemInfo: (id: number) => api.get<SystemInfo>(`/servers/${id}/ssh/system-info`),
executeSSHCommand: (id: number, command: string) =>
api.post<{ exit_code: number; stdout: string; stderr: string }>(`/servers/${id}/ssh/execute`, { command }),
}; };
// Fan Control API // Fan Control API