From 505d19a439f7b3f5da5c1a7c96d851ff71847bc8 Mon Sep 17 00:00:00 2001 From: ImpulsiveFPS Date: Sun, 1 Feb 2026 20:32:01 +0100 Subject: [PATCH] Add SSH support for lm-sensors, dynamic fan detection, improved UI --- backend/database.py | 18 +- backend/fan_control.py | 8 +- backend/main.py | 244 ++++++++++--- backend/requirements.txt | 6 +- backend/schemas.py | 63 +++- backend/ssh_client.py | 229 +++++++++++++ frontend/src/pages/FanCurves.tsx | 73 +++- frontend/src/pages/ServerDetail.tsx | 429 +++++++++++++++++------ frontend/src/pages/ServerList.tsx | 515 +++++++++++++++++++++------- frontend/src/types/index.ts | 48 ++- frontend/src/utils/api.ts | 27 +- 11 files changed, 1352 insertions(+), 308 deletions(-) create mode 100644 backend/ssh_client.py diff --git a/backend/database.py b/backend/database.py index 04f9093..a278598 100644 --- a/backend/database.py +++ b/backend/database.py @@ -45,10 +45,20 @@ class Server(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) - host = Column(String(100), nullable=False) # IP address - port = Column(Integer, default=623) - username = Column(String(100), nullable=False) - encrypted_password = Column(String(255), nullable=False) # Encrypted password + + # IPMI Settings + ipmi_host = Column(String(100), nullable=False) # IPMI IP address + 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.) vendor = Column(String(50), default="dell") diff --git a/backend/fan_control.py b/backend/fan_control.py index fdc4eb4..628e27f 100644 --- a/backend/fan_control.py +++ b/backend/fan_control.py @@ -170,10 +170,10 @@ class FanController: # Create IPMI client from backend.auth import decrypt_password client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) diff --git a/backend/main.py b/backend/main.py index 5dfe843..95c4a53 100644 --- a/backend/main.py +++ b/backend/main.py @@ -211,15 +211,28 @@ async def create_server( db: Session = Depends(get_db) ): """Add a new server.""" - # Encrypt password - encrypted_password = encrypt_password(server_data.password) + # 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, - host=server_data.host, - port=server_data.port, - username=server_data.username, - encrypted_password=encrypted_password, + # 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) @@ -257,9 +270,16 @@ async def update_server( update_data = server_data.dict(exclude_unset=True) - # Handle password update separately - if "password" in update_data: - server.encrypted_password = encrypt_password(update_data.pop("password")) + # 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(): @@ -308,10 +328,10 @@ async def get_server_status( # Test connection try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + 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() @@ -340,10 +360,10 @@ async def get_server_sensors( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -377,10 +397,10 @@ async def get_server_power( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -408,10 +428,10 @@ async def enable_manual_fan_control( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -451,10 +471,10 @@ async def disable_manual_fan_control( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -503,10 +523,10 @@ async def set_fan_speed( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -559,7 +579,7 @@ async def create_fan_curve( curve = FanCurve( server_id=server_id, 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, 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") 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.is_active = curve_data.is_active @@ -742,10 +762,10 @@ async def get_server_dashboard( try: client = IPMIClient( - host=server.host, - username=server.username, - password=decrypt_password(server.encrypted_password), - port=server.port, + host=server.ipmi_host, + username=server.ipmi_username, + password=decrypt_password(server.ipmi_encrypted_password), + port=server.ipmi_port, vendor=server.vendor ) @@ -799,6 +819,152 @@ async def get_system_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) @app.get("/api/health") async def health_check(): diff --git a/backend/requirements.txt b/backend/requirements.txt index 1eba13a..a3e8e4e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,7 @@ pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 -cryptography==42.0.0 +bcrypt==4.0.1 python-multipart==0.0.6 aiofiles==23.2.1 httpx==0.26.0 @@ -13,5 +13,5 @@ apscheduler==3.10.4 psutil==5.9.8 asyncpg==0.29.0 aiosqlite==0.19.0 -pytest==7.4.4 -pytest-asyncio==0.23.3 +cryptography==42.0.0 +asyncssh==2.14.2 diff --git a/backend/schemas.py b/backend/schemas.py index 336ef8f..a192237 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -62,15 +62,38 @@ class FanCurveResponse(FanCurveBase): class Config: 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 class ServerBase(BaseModel): - name: str - host: str - port: int = 623 - username: str - vendor: str = "dell" + name: str = Field(..., description="Server name/display name") + vendor: str = Field("dell", description="Server vendor (dell, hpe, supermicro, other)") @validator('vendor') def validate_vendor(cls, v): @@ -81,21 +104,22 @@ class ServerBase(BaseModel): class ServerCreate(ServerBase): - password: str - - @validator('port') - def validate_port(cls, v): - if not 1 <= v <= 65535: - raise ValueError('Port must be between 1 and 65535') - return v + ipmi: IPMISettings + ssh: SSHSettings = Field(default_factory=lambda: SSHSettings(use_ssh=False)) class ServerUpdate(BaseModel): name: Optional[str] = None - host: Optional[str] = None - port: Optional[int] = None - username: Optional[str] = None - password: Optional[str] = None + ipmi_host: Optional[str] = None + ipmi_port: Optional[int] = None + ipmi_username: 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 manual_control_enabled: Optional[bool] = None third_party_pcie_response: Optional[bool] = None @@ -106,6 +130,13 @@ class ServerUpdate(BaseModel): class ServerResponse(ServerBase): 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 third_party_pcie_response: bool auto_control_enabled: bool diff --git a/backend/ssh_client.py b/backend/ssh_client.py new file mode 100644 index 0000000..3121f29 --- /dev/null +++ b/backend/ssh_client.py @@ -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) diff --git a/frontend/src/pages/FanCurves.tsx b/frontend/src/pages/FanCurves.tsx index d683634..c6a022d 100644 --- a/frontend/src/pages/FanCurves.tsx +++ b/frontend/src/pages/FanCurves.tsx @@ -24,6 +24,7 @@ import { MenuItem, Alert, Divider, + CircularProgress, } from '@mui/material'; import { Add as AddIcon, @@ -34,7 +35,7 @@ import { } from '@mui/icons-material'; import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api'; 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() { const { id } = useParams<{ id: string }>(); @@ -44,6 +45,7 @@ export default function FanCurves() { const [selectedCurve, setSelectedCurve] = useState(null); const [openDialog, setOpenDialog] = useState(false); const [editingCurve, setEditingCurve] = useState(null); + const [error, setError] = useState(''); const [formData, setFormData] = useState({ name: '', sensor_source: 'cpu', @@ -80,6 +82,9 @@ export default function FanCurves() { queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); handleCloseDialog(); }, + onError: (error: any) => { + setError(error.response?.data?.detail || 'Failed to create fan curve'); + }, }); const updateMutation = useMutation({ @@ -89,6 +94,9 @@ export default function FanCurves() { queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] }); handleCloseDialog(); }, + onError: (error: any) => { + setError(error.response?.data?.detail || 'Failed to update fan curve'); + }, }); const deleteMutation = useMutation({ @@ -117,6 +125,7 @@ export default function FanCurves() { }); const handleOpenDialog = (curve?: FanCurve) => { + setError(''); if (curve) { setEditingCurve(curve); setFormData({ @@ -145,11 +154,37 @@ export default function FanCurves() { const handleCloseDialog = () => { setOpenDialog(false); setEditingCurve(null); + setError(''); }; 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 = { - name: formData.name, + name: formData.name.trim(), curve_data: formData.points, sensor_source: formData.sensor_source, is_active: true, @@ -184,6 +219,8 @@ export default function FanCurves() { } }; + const isLoading = createMutation.isPending || updateMutation.isPending; + return ( @@ -324,13 +361,6 @@ export default function FanCurves() { name === 'speed' ? 'Fan Speed' : 'Temperature', ]} /> - + {error && ( + + {error} + + )} + setFormData({ ...formData, name: e.target.value })} margin="normal" + required + error={!formData.name && !!error} + helperText={!formData.name && error ? 'Name is required' : 'Enter a name for this fan curve'} /> Sensor Source @@ -405,6 +444,7 @@ export default function FanCurves() { updatePoint(index, 'temp', parseInt(e.target.value) || 0) } sx={{ flex: 1 }} + inputProps={{ min: 0, max: 150 }} /> - + diff --git a/frontend/src/pages/ServerDetail.tsx b/frontend/src/pages/ServerDetail.tsx index d73b9eb..e57421d 100644 --- a/frontend/src/pages/ServerDetail.tsx +++ b/frontend/src/pages/ServerDetail.tsx @@ -24,16 +24,17 @@ import { TableRow, Switch, FormControlLabel, + Tooltip, + IconButton, } from '@mui/material'; import { Speed as SpeedIcon, Thermostat as TempIcon, Power as PowerIcon, + Refresh as RefreshIcon, } from '@mui/icons-material'; import { serversApi, fanControlApi, dashboardApi } from '../utils/api'; - - interface TabPanelProps { children?: React.ReactNode; index: number; @@ -65,7 +66,7 @@ export default function ServerDetail() { }, }); - const { data: sensors } = useQuery({ + const { data: sensors, refetch: refetchSensors } = useQuery({ queryKey: ['sensors', serverId], queryFn: async () => { const response = await serversApi.getSensors(serverId); @@ -74,6 +75,22 @@ export default function ServerDetail() { 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({ queryKey: ['dashboard-server', serverId], queryFn: async () => { @@ -123,7 +140,14 @@ export default function ServerDetail() { } 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 ( @@ -131,7 +155,8 @@ export default function ServerDetail() { {server.name} - {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}`} @@ -143,6 +168,9 @@ export default function ServerDetail() { {server.auto_control_enabled && ( )} + {server.use_ssh && ( + + )} @@ -156,42 +184,79 @@ export default function ServerDetail() { {/* Overview Tab */} + {/* CPU Temps from SSH (preferred) or IPMI */} - CPU Temperatures + CPU Core Temperatures - - {cpuTemps.map((temp) => ( - - - {temp.value.toFixed(1)}°C - - {temp.name} + + {isSSHSensorsLoading ? ( + + + + Loading SSH sensors... + + + ) : sshCPUTemps.length > 0 ? ( + <> + {sshCPUTemps.map((cpu: any, idx: number) => ( + + + {cpu.cpu_name || `CPU ${idx + 1}`} + {cpu.package_temp && ` (Package: ${cpu.package_temp}°C)`} - - - - ))} - {cpuTemps.length === 0 && ( - - - No CPU temperature data available - - - )} - + + {Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => ( + + + + {temp as number}°C + + + {coreName} + + + + ))} + + + ))} + + ) : cpuTemps.length > 0 ? ( + + {cpuTemps.map((temp) => ( + + + {temp.value.toFixed(1)}°C + + {temp.name} + + + + + ))} + + ) : ( + + No CPU temperature data available. Enable SSH for detailed core temperatures. + + )} + {/* Fan Speeds */} @@ -231,6 +296,7 @@ export default function ServerDetail() { + {/* Power Consumption */} {dashboardData?.power_consumption && ( @@ -240,16 +306,35 @@ export default function ServerDetail() { Power Consumption - {Object.entries(dashboardData.power_consumption).map(([key, value]) => ( - - - - {key} - - {value} - - - ))} + {Object.entries(dashboardData.power_consumption) + .filter(([_, value]) => !value.includes('UTC')) // Filter out weird timestamp entries + .slice(0, 4) + .map(([key, value]) => { + // Clean up the display + let displayValue = value as string; + let displayKey = key; + + // 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 ( + + + + {displayKey.replace(/_/g, ' ')} + + + {displayValue} + + + + ); + })} @@ -265,8 +350,13 @@ export default function ServerDetail() { - Control Mode + Manual Fan Control + + + Enable manual control to set fan speeds. When disabled, the server uses automatic fan control. + + } - label="Manual Fan Control" + label="Enable Manual Fan Control" /> @@ -291,54 +381,75 @@ export default function ServerDetail() { Set Fan Speed - + + {detectedFans.length === 0 && ( + + No fans detected via IPMI. Please check your server connection. + + )} + + - 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'} + + + + + {detectedFans.map((fan) => ( + + ))} + + + + + + Speed: {fanSpeed}% `${v}%`} + disabled={detectedFans.length === 0} /> - - - {[0, 1, 2, 3, 4, 5, 6].map((i) => ( - - ))} - - + + )} @@ -356,15 +467,19 @@ export default function ServerDetail() { control={} label="Panic Mode (Auto 100% on sensor loss)" /> + + Timeout: {server.panic_timeout_seconds} seconds + - - Timeout: {server.panic_timeout_seconds} seconds - - + + } label="3rd Party PCIe Card Response" /> + + Controls fan response when using non-Dell PCIe cards + @@ -372,15 +487,106 @@ export default function ServerDetail() { - {/* Sensors Tab */} + {/* Sensors Tab - Merged IPMI and SSH */} + {/* SSH Core Temps */} + {isSSHSensorsLoading ? ( + + + + + + Loading SSH sensors... + + + + + ) : sshCPUTemps.length > 0 && ( + + + + + CPU Core Temperatures (lm-sensors via SSH) + + + {sshCPUTemps.map((cpu: any, idx: number) => ( + + + + {cpu.cpu_name || `CPU ${idx + 1}`} + {cpu.package_temp && ( + + )} + + + + + + Core + Temp + + + + {Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => ( + + {coreName} + + + + + ))} + +
+
+
+
+ ))} +
+
+
+
+ )} + + {/* Raw SSH Sensors Data (if available) */} + {sshSensors?.raw_data && ( + + + + + Raw lm-sensors Data + + +
+                      {JSON.stringify(sshSensors.raw_data, null, 2)}
+                    
+
+
+
+
+ )} + + {/* IPMI Temperatures */} - - All Temperature Sensors - + + IPMI Temperature Sensors + + refetchSensors()}> + + + + @@ -413,14 +619,15 @@ export default function ServerDetail() { + {/* IPMI All Sensors */} - All Sensors + All IPMI Sensors - -
+ +
Sensor @@ -429,7 +636,7 @@ export default function ServerDetail() { - {sensors?.all_sensors.slice(0, 20).map((sensor) => ( + {sensors?.all_sensors.slice(0, 50).map((sensor) => ( {sensor.name} {sensor.sensor_type} @@ -457,16 +664,46 @@ export default function ServerDetail() { {dashboardData?.power_consumption ? ( - {Object.entries(dashboardData.power_consumption).map(([key, value]) => ( - - - {value} - - {key} - - - - ))} + {Object.entries(dashboardData.power_consumption) + .filter(([_key, value]) => { + // Filter out entries that look like timestamps or are too messy + const valueStr = String(value); + return !valueStr.includes('UTC') && + !valueStr.includes('Peak Time') && + !valueStr.includes('Statistic') && + valueStr.length < 50; + }) + .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 ( + + + + {displayKey.replace(/_/g, ' ')} + + + {displayValue} + + + + ); + })} ) : ( diff --git a/frontend/src/pages/ServerList.tsx b/frontend/src/pages/ServerList.tsx index 4c327f0..9bbdb4c 100644 --- a/frontend/src/pages/ServerList.tsx +++ b/frontend/src/pages/ServerList.tsx @@ -26,29 +26,47 @@ import { Alert, CircularProgress, Tooltip, + Divider, + Switch, + FormControlLabel, + Stepper, + Step, + StepLabel, } from '@mui/material'; import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, - Settings as SettingsIcon, Speed as SpeedIcon, + Computer as ComputerIcon, } from '@mui/icons-material'; import { serversApi } from '../utils/api'; import type { Server } from '../types'; +const STEPS = ['IPMI Connection', 'SSH Connection (Optional)', 'Review']; + export default function ServerList() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [openDialog, setOpenDialog] = useState(false); const [editingServer, setEditingServer] = useState(null); + const [activeStep, setActiveStep] = useState(0); + const [formData, setFormData] = useState({ + // Basic name: '', - host: '', - port: 623, - username: '', - password: '', 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(''); @@ -95,21 +113,32 @@ export default function ServerList() { setEditingServer(server); setFormData({ name: server.name, - host: server.host, - port: server.port, - username: server.username, - password: '', // Don't show password 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 { setEditingServer(null); + setActiveStep(0); setFormData({ name: '', - host: '', - port: 623, - username: '', - password: '', vendor: 'dell', + ipmi_host: '', + ipmi_port: 623, + ipmi_username: '', + ipmi_password: '', + use_ssh: false, + ssh_host: '', + ssh_port: 22, + ssh_username: '', + ssh_password: '', }); } setFormError(''); @@ -119,34 +148,278 @@ export default function ServerList() { const handleCloseDialog = () => { setOpenDialog(false); setEditingServer(null); + setActiveStep(0); setFormError(''); }; - const handleSubmit = () => { - if (!formData.name || !formData.host || !formData.username) { - setFormError('Please fill in all required fields'); - return; + const validateStep = (step: number): boolean => { + if (step === 0) { + // Validate IPMI fields + 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) { - setFormError('Password is required for new servers'); - return; + const handleNext = () => { + if (validateStep(activeStep)) { + 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) { const updateData: any = { - name: formData.name, - host: formData.host, - port: formData.port, - username: formData.username, - vendor: formData.vendor, + name: data.name, + vendor: data.vendor, + ipmi_host: data.ipmi.ipmi_host, + ipmi_port: data.ipmi.ipmi_port, + 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) { - updateData.password = formData.password; + if (data.ipmi.ipmi_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 }); } else { - createMutation.mutate(formData as any); + createMutation.mutate(data); + } + }; + + const renderStepContent = (step: number) => { + switch (step) { + case 0: + return ( + + + + IPMI Connection (Required) + + + Enter the IPMI/iDRAC/iLO credentials for fan control and basic sensor reading. + + + setFormData({ ...formData, name: e.target.value })} + margin="normal" + required + helperText="A friendly name for this server" + /> + + + Server Vendor + + + + 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" + /> + + setFormData({ ...formData, ipmi_port: parseInt(e.target.value) || 623 })} + margin="normal" + helperText="Default is 623 for most servers" + /> + + setFormData({ ...formData, ipmi_username: e.target.value })} + margin="normal" + required + placeholder="root" + /> + + setFormData({ ...formData, ipmi_password: e.target.value })} + margin="normal" + required={!editingServer} + /> + + ); + + case 1: + return ( + + + SSH Connection (Optional) + + + SSH provides more detailed sensor data via lm-sensors (CPU core temperatures, etc.). + If not configured, only IPMI sensors will be used. + + + setFormData({ ...formData, use_ssh: e.target.checked })} + /> + } + label="Enable SSH for detailed sensor data" + /> + + {formData.use_ssh && ( + <> + + + setFormData({ ...formData, ssh_host: e.target.value })} + margin="normal" + placeholder={formData.ipmi_host} + helperText="Leave empty to use IPMI host" + /> + + setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })} + margin="normal" + /> + + setFormData({ ...formData, ssh_username: e.target.value })} + margin="normal" + placeholder={formData.ipmi_username} + helperText="Leave empty to use IPMI username" + /> + + setFormData({ ...formData, ssh_password: e.target.value })} + margin="normal" + helperText="Password for SSH authentication" + /> + + )} + + ); + + case 2: + return ( + + + Review Configuration + + + + + Basic Info + + Name: {formData.name} + Vendor: {formData.vendor.toUpperCase()} + + + + + IPMI Connection + + Host: {formData.ipmi_host}:{formData.ipmi_port} + Username: {formData.ipmi_username} + + + + + SSH Connection + + + Status: {formData.use_ssh ? 'Enabled' : 'Disabled'} + + {formData.use_ssh && ( + <> + Host: {formData.ssh_host || formData.ipmi_host}:{formData.ssh_port} + Username: {formData.ssh_username || formData.ipmi_username} + + )} + + + ); + + default: + return null; } }; @@ -186,8 +459,8 @@ export default function ServerList() { Name - Host - Vendor + IPMI Host + SSH Status Last Seen Actions @@ -195,39 +468,55 @@ export default function ServerList() { {servers?.map((server) => ( - - {server.name} - {server.host} - {server.vendor.toUpperCase()} + navigate(`/servers/${server.id}`)} + sx={{ cursor: 'pointer' }} + > + + + {server.name} + + + + {server.ipmi_host} + + {server.use_ssh ? ( + + ) : ( + + )} + {getStatusChip(server)} {server.last_seen ? new Date(server.last_seen).toLocaleString() : 'Never'} - + e.stopPropagation()}> navigate(`/servers/${server.id}/curves`)} + onClick={(e) => { + e.stopPropagation(); + navigate(`/servers/${server.id}/curves`); + }} > - - navigate(`/servers/${server.id}`)} - > - - - - handleOpenDialog(server)}> + { + e.stopPropagation(); + handleOpenDialog(server); + }}> { + onClick={(e) => { + e.stopPropagation(); if (confirm('Are you sure you want to delete this server?')) { deleteMutation.mutate(server.id); } @@ -254,7 +543,13 @@ export default function ServerList() { {/* Add/Edit Dialog */} - + {editingServer ? 'Edit Server' : 'Add Server'} @@ -264,84 +559,62 @@ export default function ServerList() { {formError} )} - setFormData({ ...formData, name: e.target.value })} - margin="normal" - required - /> - setFormData({ ...formData, host: e.target.value })} - margin="normal" - required - /> - - setFormData({ ...formData, port: parseInt(e.target.value) || 623 }) - } - margin="normal" - /> - - setFormData({ ...formData, username: e.target.value }) - } - margin="normal" - required - /> - - setFormData({ ...formData, password: e.target.value }) - } - margin="normal" - required={!editingServer} - /> - - Vendor - - + + {!editingServer && ( + + {STEPS.map((label) => ( + + {label} + + ))} + + )} + + {editingServer ? ( + // Editing mode - show all fields at once + + Basic Info + setFormData({ ...formData, name: e.target.value })} margin="normal" /> + + IPMI + setFormData({ ...formData, ipmi_host: e.target.value })} margin="normal" /> + setFormData({ ...formData, ipmi_username: e.target.value })} margin="normal" /> + setFormData({ ...formData, ipmi_password: e.target.value })} margin="normal" /> + + SSH + setFormData({ ...formData, use_ssh: e.target.checked })} />} label="Enable SSH" /> + {formData.use_ssh && ( + <> + setFormData({ ...formData, ssh_host: e.target.value })} margin="normal" /> + setFormData({ ...formData, ssh_username: e.target.value })} margin="normal" /> + setFormData({ ...formData, ssh_password: e.target.value })} margin="normal" /> + + )} + + ) : ( + // Creating mode - show stepper + renderStepContent(activeStep) + )} - + + {editingServer ? ( + + ) : ( + <> + + {activeStep === STEPS.length - 1 ? ( + + ) : ( + + )} + + )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 824d8e8..224b959 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -6,12 +6,37 @@ export interface User { 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 { id: number; name: string; - host: string; - port: number; - username: string; + // IPMI + ipmi_host: string; + ipmi_port: number; + ipmi_username: string; + // SSH + ssh_host?: string; + ssh_port: number; + ssh_username?: string; + use_ssh: boolean; + // Other vendor: string; manual_control_enabled: boolean; third_party_pcie_response: boolean; @@ -107,3 +132,20 @@ export interface AutoControlSettings { enabled: boolean; curve_id?: number; } + +// SSH Sensor Data +export interface SSHSensorData { + cpu_temps: { + cpu_name: string; + core_temps: Record; + package_temp: number | null; + }[]; + raw_data: Record; +} + +export interface SystemInfo { + cpu?: string; + memory?: string; + os?: string; + uptime?: string; +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 12e6f97..0be801a 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -13,6 +13,8 @@ import type { ServerSensors, FanControlCommand, AutoControlSettings, + SSHSensorData, + SystemInfo, } from '../types'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -72,18 +74,33 @@ export const serversApi = { getById: (id: number) => api.get(`/servers/${id}`), create: (data: { name: string; - host: string; - port: number; - username: string; - password: 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('/servers', data), - update: (id: number, data: Partial & { password?: string }) => + update: (id: number, data: any) => api.put(`/servers/${id}`, data), delete: (id: number) => api.delete(`/servers/${id}`), getStatus: (id: number) => api.get(`/servers/${id}/status`), getSensors: (id: number) => api.get(`/servers/${id}/sensors`), getPower: (id: number) => api.get>(`/servers/${id}/power`), + // SSH endpoints + getSSHSensors: (id: number) => api.get(`/servers/${id}/ssh/sensors`), + getSSHSystemInfo: (id: number) => api.get(`/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