Add SSH support for lm-sensors, dynamic fan detection, improved UI
This commit is contained in:
parent
16c7d09a44
commit
505d19a439
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
244
backend/main.py
244
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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -63,14 +63,37 @@ 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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<FanCurve | null>(null);
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
|
|
@ -324,13 +361,6 @@ export default function FanCurves() {
|
|||
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="speed"
|
||||
stroke="#8884d8"
|
||||
fill="#8884d8"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="speed"
|
||||
|
|
@ -366,12 +396,21 @@ export default function FanCurves() {
|
|||
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
label="Curve Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => 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'}
|
||||
/>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>Sensor Source</InputLabel>
|
||||
|
|
@ -405,6 +444,7 @@ export default function FanCurves() {
|
|||
updatePoint(index, 'temp', parseInt(e.target.value) || 0)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
inputProps={{ min: 0, max: 150 }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
|
|
@ -460,17 +500,16 @@ export default function FanCurves() {
|
|||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button onClick={handleCloseDialog} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={
|
||||
!formData.name ||
|
||||
createMutation.isPending ||
|
||||
updateMutation.isPending
|
||||
}
|
||||
disabled={isLoading || !formData.name.trim()}
|
||||
startIcon={isLoading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{editingCurve ? 'Update' : 'Create'}
|
||||
{isLoading ? 'Saving...' : editingCurve ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -124,6 +141,13 @@ 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 (
|
||||
<Box>
|
||||
|
|
@ -131,7 +155,8 @@ export default function ServerDetail() {
|
|||
<Box>
|
||||
<Typography variant="h4">{server.name}</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
|
|
@ -143,6 +168,9 @@ export default function ServerDetail() {
|
|||
{server.auto_control_enabled && (
|
||||
<Chip color="success" label="Auto Curve" />
|
||||
)}
|
||||
{server.use_ssh && (
|
||||
<Chip color="info" label="SSH Enabled" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
@ -156,42 +184,79 @@ export default function ServerDetail() {
|
|||
{/* Overview Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Grid container spacing={3}>
|
||||
{/* CPU Temps from SSH (preferred) or IPMI */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TempIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
CPU Temperatures
|
||||
CPU Core Temperatures
|
||||
</Typography>
|
||||
<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}
|
||||
|
||||
{isSSHSensorsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
Loading SSH sensors...
|
||||
</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>
|
||||
<Chip
|
||||
size="small"
|
||||
label={temp.status}
|
||||
color={temp.status === 'ok' ? 'success' : 'error'}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
{cpuTemps.length === 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography color="text.secondary" align="center">
|
||||
No CPU temperature data available
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={1}>
|
||||
{Object.entries(cpu.core_temps || {}).map(([coreName, temp]) => (
|
||||
<Grid item xs={6} sm={4} key={coreName}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{ p: 1.5, textAlign: 'center' }}
|
||||
>
|
||||
<Typography variant="h6" color="success">
|
||||
{temp as number}°C
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{coreName}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</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>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Fan Speeds */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
|
@ -231,6 +296,7 @@ export default function ServerDetail() {
|
|||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Power Consumption */}
|
||||
{dashboardData?.power_consumption && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
|
|
@ -240,16 +306,35 @@ export default function ServerDetail() {
|
|||
Power Consumption
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(dashboardData.power_consumption).map(([key, value]) => (
|
||||
<Grid item xs={6} md={3} key={key}>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{key}
|
||||
</Typography>
|
||||
<Typography variant="h6">{value}</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
{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 (
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -265,8 +350,13 @@ export default function ServerDetail() {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Control Mode
|
||||
Manual Fan Control
|
||||
</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 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
|
@ -281,7 +371,7 @@ export default function ServerDetail() {
|
|||
}}
|
||||
/>
|
||||
}
|
||||
label="Manual Fan Control"
|
||||
label="Enable Manual Fan Control"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
|
@ -291,54 +381,75 @@ export default function ServerDetail() {
|
|||
<Typography variant="h6" gutterBottom>
|
||||
Set Fan Speed
|
||||
</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>
|
||||
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>
|
||||
<Slider
|
||||
value={fanSpeed}
|
||||
onChange={handleFanSpeedChange}
|
||||
min={0}
|
||||
min={10}
|
||||
max={100}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: 0, label: '0%' },
|
||||
{ value: 10, label: '10%' },
|
||||
{ value: 50, label: '50%' },
|
||||
{ value: 100, label: '100%' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
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>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
onClick={handleApplyFanSpeed}
|
||||
disabled={setFanSpeedMutation.isPending || detectedFans.length === 0}
|
||||
startIcon={<SpeedIcon />}
|
||||
>
|
||||
Apply {fanSpeed}% Speed
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -356,15 +467,19 @@ export default function ServerDetail() {
|
|||
control={<Switch checked={server.panic_mode_enabled} disabled />}
|
||||
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>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Timeout: {server.panic_timeout_seconds} seconds
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={server.third_party_pcie_response} disabled />}
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -372,15 +487,106 @@ export default function ServerDetail() {
|
|||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Sensors Tab */}
|
||||
{/* Sensors Tab - Merged IPMI and SSH */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<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}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
All Temperature Sensors
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">IPMI Temperature Sensors</Typography>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton onClick={() => refetchSensors()}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -413,14 +619,15 @@ export default function ServerDetail() {
|
|||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* IPMI All Sensors */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
All Sensors
|
||||
All IPMI Sensors
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableContainer sx={{ maxHeight: 400 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Sensor</TableCell>
|
||||
|
|
@ -429,7 +636,7 @@ export default function ServerDetail() {
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sensors?.all_sensors.slice(0, 20).map((sensor) => (
|
||||
{sensors?.all_sensors.slice(0, 50).map((sensor) => (
|
||||
<TableRow key={sensor.name}>
|
||||
<TableCell>{sensor.name}</TableCell>
|
||||
<TableCell>{sensor.sensor_type}</TableCell>
|
||||
|
|
@ -457,16 +664,46 @@ export default function ServerDetail() {
|
|||
</Typography>
|
||||
{dashboardData?.power_consumption ? (
|
||||
<Grid container spacing={3}>
|
||||
{Object.entries(dashboardData.power_consumption).map(([key, value]) => (
|
||||
<Grid item xs={12} md={4} key={key}>
|
||||
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{key}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
{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 (
|
||||
<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>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
|
|
|
|||
|
|
@ -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<Server | null>(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 (
|
||||
<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>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Host</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>IPMI Host</TableCell>
|
||||
<TableCell>SSH</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Seen</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
|
|
@ -195,39 +468,55 @@ export default function ServerList() {
|
|||
</TableHead>
|
||||
<TableBody>
|
||||
{servers?.map((server) => (
|
||||
<TableRow key={server.id} hover>
|
||||
<TableCell>{server.name}</TableCell>
|
||||
<TableCell>{server.host}</TableCell>
|
||||
<TableCell>{server.vendor.toUpperCase()}</TableCell>
|
||||
<TableRow
|
||||
key={server.id}
|
||||
hover
|
||||
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>
|
||||
{server.last_seen
|
||||
? new Date(server.last_seen).toLocaleString()
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title="Fan Curves">
|
||||
<IconButton
|
||||
onClick={() => navigate(`/servers/${server.id}/curves`)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/servers/${server.id}/curves`);
|
||||
}}
|
||||
>
|
||||
<SpeedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings">
|
||||
<IconButton
|
||||
onClick={() => navigate(`/servers/${server.id}`)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton onClick={() => handleOpenDialog(server)}>
|
||||
<IconButton onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenDialog(server);
|
||||
}}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
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() {
|
|||
</TableContainer>
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
fullScreen={false}
|
||||
>
|
||||
<DialogTitle>
|
||||
{editingServer ? 'Edit Server' : 'Add Server'}
|
||||
</DialogTitle>
|
||||
|
|
@ -264,84 +559,62 @@ export default function ServerList() {
|
|||
{formError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="IP Address / Hostname"
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Port"
|
||||
value={formData.port}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, port: parseInt(e.target.value) || 623 })
|
||||
}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
{!editingServer && (
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 2 }}>
|
||||
{STEPS.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
|
||||
{editingServer ? (
|
||||
// Editing mode - show all fields at once
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>Basic Info</Typography>
|
||||
<TextField fullWidth label="Name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} margin="normal" />
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>IPMI</Typography>
|
||||
<TextField fullWidth label="IPMI Host" value={formData.ipmi_host} onChange={(e) => setFormData({ ...formData, ipmi_host: e.target.value })} margin="normal" />
|
||||
<TextField fullWidth label="IPMI Username" value={formData.ipmi_username} onChange={(e) => setFormData({ ...formData, ipmi_username: e.target.value })} margin="normal" />
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
{formData.use_ssh && (
|
||||
<>
|
||||
<TextField fullWidth label="SSH Host" value={formData.ssh_host} onChange={(e) => setFormData({ ...formData, ssh_host: e.target.value })} margin="normal" />
|
||||
<TextField fullWidth label="SSH Username" value={formData.ssh_username} onChange={(e) => setFormData({ ...formData, ssh_username: e.target.value })} margin="normal" />
|
||||
<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" />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// Creating mode - show stepper
|
||||
renderStepContent(activeStep)
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending ? (
|
||||
<CircularProgress size={24} />
|
||||
) : editingServer ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Add'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{editingServer ? (
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? <CircularProgress size={24} /> : 'Update'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleBack} disabled={activeStep === 0}>Back</Button>
|
||||
{activeStep === STEPS.length - 1 ? (
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? <CircularProgress size={24} /> : 'Create'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleNext} variant="contained">Next</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
package_temp: number | null;
|
||||
}[];
|
||||
raw_data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
cpu?: string;
|
||||
memory?: string;
|
||||
os?: string;
|
||||
uptime?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Server>(`/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<Server>('/servers', data),
|
||||
update: (id: number, data: Partial<Server> & { password?: string }) =>
|
||||
update: (id: number, data: any) =>
|
||||
api.put<Server>(`/servers/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/servers/${id}`),
|
||||
getStatus: (id: number) => api.get<ServerStatus>(`/servers/${id}/status`),
|
||||
getSensors: (id: number) => api.get<ServerSensors>(`/servers/${id}/sensors`),
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue