Compare commits
No commits in common. "3de9b38388ec65432fa47e7e7a8a0dfa77650ec4" and "505d19a439f7b3f5da5c1a7c96d851ff71847bc8" have entirely different histories.
3de9b38388
...
505d19a439
|
|
@ -1,63 +0,0 @@
|
||||||
"""Simple in-memory cache for expensive operations."""
|
|
||||||
import time
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from typing import Any, Optional, Callable
|
|
||||||
import threading
|
|
||||||
|
|
||||||
class Cache:
|
|
||||||
"""Thread-safe in-memory cache with TTL."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._cache: dict = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def get(self, key: str) -> Optional[Any]:
|
|
||||||
"""Get value from cache if not expired."""
|
|
||||||
with self._lock:
|
|
||||||
if key not in self._cache:
|
|
||||||
return None
|
|
||||||
|
|
||||||
entry = self._cache[key]
|
|
||||||
if entry['expires'] < time.time():
|
|
||||||
del self._cache[key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
return entry['value']
|
|
||||||
|
|
||||||
def set(self, key: str, value: Any, ttl: int = 60):
|
|
||||||
"""Set value in cache with TTL (seconds)."""
|
|
||||||
with self._lock:
|
|
||||||
self._cache[key] = {
|
|
||||||
'value': value,
|
|
||||||
'expires': time.time() + ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete(self, key: str):
|
|
||||||
"""Delete key from cache."""
|
|
||||||
with self._lock:
|
|
||||||
if key in self._cache:
|
|
||||||
del self._cache[key]
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Clear all cache."""
|
|
||||||
with self._lock:
|
|
||||||
self._cache.clear()
|
|
||||||
|
|
||||||
def get_or_set(self, key: str, factory: Callable, ttl: int = 60) -> Any:
|
|
||||||
"""Get from cache or call factory and cache result."""
|
|
||||||
value = self.get(key)
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
|
|
||||||
value = factory()
|
|
||||||
self.set(key, value, ttl)
|
|
||||||
return value
|
|
||||||
|
|
||||||
# Global cache instance
|
|
||||||
cache = Cache()
|
|
||||||
|
|
||||||
def make_key(*args, **kwargs) -> str:
|
|
||||||
"""Create cache key from arguments."""
|
|
||||||
key_data = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, default=str)
|
|
||||||
return hashlib.md5(key_data.encode()).hexdigest()
|
|
||||||
|
|
@ -102,12 +102,12 @@ class SensorData(Base):
|
||||||
__tablename__ = "sensor_data"
|
__tablename__ = "sensor_data"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False, index=True)
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||||
sensor_name = Column(String(100), nullable=False, index=True)
|
sensor_name = Column(String(100), nullable=False)
|
||||||
sensor_type = Column(String(50), nullable=False, index=True) # temperature, voltage, fan, power
|
sensor_type = Column(String(50), nullable=False) # temperature, voltage, fan, power
|
||||||
value = Column(Float, nullable=False)
|
value = Column(Float, nullable=False)
|
||||||
unit = Column(String(20), nullable=True)
|
unit = Column(String(20), nullable=True)
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
server = relationship("Server", back_populates="sensor_data")
|
server = relationship("Server", back_populates="sensor_data")
|
||||||
|
|
||||||
|
|
@ -117,13 +117,13 @@ class FanData(Base):
|
||||||
__tablename__ = "fan_data"
|
__tablename__ = "fan_data"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False, index=True)
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||||
fan_number = Column(Integer, nullable=False)
|
fan_number = Column(Integer, nullable=False)
|
||||||
fan_id = Column(String(20), nullable=False) # IPMI fan ID (0x00, 0x01, etc.)
|
fan_id = Column(String(20), nullable=False) # IPMI fan ID (0x00, 0x01, etc.)
|
||||||
speed_rpm = Column(Integer, nullable=True)
|
speed_rpm = Column(Integer, nullable=True)
|
||||||
speed_percent = Column(Integer, nullable=True)
|
speed_percent = Column(Integer, nullable=True)
|
||||||
is_manual = Column(Boolean, default=False)
|
is_manual = Column(Boolean, default=False)
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
server = relationship("Server", back_populates="fan_data")
|
server = relationship("Server", back_populates="fan_data")
|
||||||
|
|
||||||
|
|
@ -133,11 +133,11 @@ class SystemLog(Base):
|
||||||
__tablename__ = "system_logs"
|
__tablename__ = "system_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=True, index=True)
|
server_id = Column(Integer, ForeignKey("servers.id"), nullable=True)
|
||||||
event_type = Column(String(50), nullable=False, index=True) # panic, fan_change, error, warning, info
|
event_type = Column(String(50), nullable=False) # panic, fan_change, error, warning, info
|
||||||
message = Column(Text, nullable=False)
|
message = Column(Text, nullable=False)
|
||||||
details = Column(Text, nullable=True)
|
details = Column(Text, nullable=True)
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(Base):
|
class AppSettings(Base):
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from typing import List, Dict, Optional, Any
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -63,7 +62,9 @@ class FanCurveManager:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_speed(curve: List[FanCurvePoint], temperature: float) -> int:
|
def calculate_speed(curve: List[FanCurvePoint], temperature: float) -> int:
|
||||||
"""Calculate fan speed for a given temperature using linear interpolation."""
|
"""
|
||||||
|
Calculate fan speed for a given temperature using linear interpolation.
|
||||||
|
"""
|
||||||
if not curve:
|
if not curve:
|
||||||
return 50 # Default to 50% if no curve
|
return 50 # Default to 50% if no curve
|
||||||
|
|
||||||
|
|
@ -101,8 +102,8 @@ class FanController:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.curve_manager = FanCurveManager()
|
self.curve_manager = FanCurveManager()
|
||||||
self.running = False
|
self.running = False
|
||||||
self._tasks: Dict[int, asyncio.Task] = {}
|
self._tasks: Dict[int, asyncio.Task] = {} # server_id -> task
|
||||||
self._last_sensor_data: Dict[int, datetime] = {}
|
self._last_sensor_data: Dict[int, datetime] = {} # server_id -> timestamp
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the fan controller service."""
|
"""Start the fan controller service."""
|
||||||
|
|
@ -166,9 +167,8 @@ class FanController:
|
||||||
if not server or not server.is_active:
|
if not server or not server.is_active:
|
||||||
return
|
return
|
||||||
|
|
||||||
from backend.auth import decrypt_password
|
|
||||||
|
|
||||||
# Create IPMI client
|
# Create IPMI client
|
||||||
|
from backend.auth import decrypt_password
|
||||||
client = IPMIClient(
|
client = IPMIClient(
|
||||||
host=server.ipmi_host,
|
host=server.ipmi_host,
|
||||||
username=server.ipmi_username,
|
username=server.ipmi_username,
|
||||||
|
|
@ -177,37 +177,112 @@ class FanController:
|
||||||
vendor=server.vendor
|
vendor=server.vendor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test connection with timeout
|
# Test connection
|
||||||
if not await asyncio.wait_for(
|
if not client.test_connection():
|
||||||
asyncio.to_thread(client.test_connection),
|
|
||||||
timeout=10.0
|
|
||||||
):
|
|
||||||
logger.warning(f"Cannot connect to server {server.name}")
|
logger.warning(f"Cannot connect to server {server.name}")
|
||||||
|
await self._handle_connection_loss(db, server)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get sensor data with timeout
|
# Get sensor data
|
||||||
temps = await asyncio.wait_for(
|
temps = client.get_temperatures()
|
||||||
asyncio.to_thread(client.get_temperatures),
|
fans = client.get_fan_speeds()
|
||||||
timeout=15.0
|
all_sensors = client.get_all_sensors()
|
||||||
)
|
|
||||||
|
# Store sensor data
|
||||||
|
self._store_sensor_data(db, server_id, temps, fans, all_sensors)
|
||||||
|
|
||||||
# Update last sensor data time
|
# Update last sensor data time
|
||||||
self._last_sensor_data[server_id] = datetime.utcnow()
|
self._last_sensor_data[server_id] = datetime.utcnow()
|
||||||
server.last_seen = datetime.utcnow()
|
server.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
# Check panic mode
|
||||||
|
if self._should_panic(db, server_id, server):
|
||||||
|
await self._enter_panic_mode(db, server, client)
|
||||||
|
return
|
||||||
|
|
||||||
# Calculate and set fan speed if auto control is enabled
|
# Calculate and set fan speed if auto control is enabled
|
||||||
if server.auto_control_enabled:
|
if server.auto_control_enabled:
|
||||||
await self._apply_fan_curve(db, server, client, temps)
|
await self._apply_fan_curve(db, server, client, temps)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"Control iteration timeout for server {server_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Control iteration error for server {server_id}: {e}")
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
def _store_sensor_data(self, db: Session, server_id: int,
|
||||||
|
temps: List[TemperatureReading],
|
||||||
|
fans: List[Any],
|
||||||
|
all_sensors: List[Any]):
|
||||||
|
"""Store sensor data in database."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Store temperature readings
|
||||||
|
for temp in temps:
|
||||||
|
sensor = SensorData(
|
||||||
|
server_id=server_id,
|
||||||
|
sensor_name=temp.name,
|
||||||
|
sensor_type="temperature",
|
||||||
|
value=temp.value,
|
||||||
|
unit="°C",
|
||||||
|
timestamp=now
|
||||||
|
)
|
||||||
|
db.add(sensor)
|
||||||
|
|
||||||
|
# Store fan readings
|
||||||
|
for fan in fans:
|
||||||
|
fan_data = FanData(
|
||||||
|
server_id=server_id,
|
||||||
|
fan_number=fan.fan_number,
|
||||||
|
fan_id=fan.fan_id,
|
||||||
|
speed_rpm=fan.speed_rpm,
|
||||||
|
speed_percent=fan.speed_percent,
|
||||||
|
is_manual=False,
|
||||||
|
timestamp=now
|
||||||
|
)
|
||||||
|
db.add(fan_data)
|
||||||
|
|
||||||
|
def _should_panic(self, db: Session, server_id: int, server: Server) -> bool:
|
||||||
|
"""Check if we should enter panic mode."""
|
||||||
|
if not server.panic_mode_enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_seen = self._last_sensor_data.get(server_id)
|
||||||
|
if not last_seen:
|
||||||
|
return False
|
||||||
|
|
||||||
|
timeout = server.panic_timeout_seconds or settings.PANIC_TIMEOUT_SECONDS
|
||||||
|
elapsed = (datetime.utcnow() - last_seen).total_seconds()
|
||||||
|
|
||||||
|
if elapsed > timeout:
|
||||||
|
logger.warning(f"Panic mode triggered for server {server.name}: "
|
||||||
|
f"No sensor data for {elapsed:.0f}s")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _enter_panic_mode(self, db: Session, server: Server, client: IPMIClient):
|
||||||
|
"""Enter panic mode - set fans to 100%."""
|
||||||
|
logger.critical(f"Entering PANIC MODE for server {server.name}")
|
||||||
|
|
||||||
|
# Log the event
|
||||||
|
log = SystemLog(
|
||||||
|
server_id=server.id,
|
||||||
|
event_type="panic",
|
||||||
|
message=f"Panic mode activated - No sensor data received",
|
||||||
|
details=f"Setting all fans to {settings.PANIC_FAN_SPEED}%"
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
# Enable manual control if not already
|
||||||
|
if not server.manual_control_enabled:
|
||||||
|
client.enable_manual_fan_control()
|
||||||
|
server.manual_control_enabled = True
|
||||||
|
|
||||||
|
# Set fans to max
|
||||||
|
client.set_all_fans_speed(settings.PANIC_FAN_SPEED)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
async def _apply_fan_curve(self, db: Session, server: Server,
|
async def _apply_fan_curve(self, db: Session, server: Server,
|
||||||
client: IPMIClient, temps: List[TemperatureReading]):
|
client: IPMIClient, temps: List[TemperatureReading]):
|
||||||
"""Apply fan curve based on temperatures."""
|
"""Apply fan curve based on temperatures."""
|
||||||
|
|
@ -217,6 +292,7 @@ class FanController:
|
||||||
# Get active fan curve
|
# Get active fan curve
|
||||||
curve_data = server.fan_curve_data
|
curve_data = server.fan_curve_data
|
||||||
if not curve_data:
|
if not curve_data:
|
||||||
|
# Use default curve
|
||||||
curve = [
|
curve = [
|
||||||
FanCurvePoint(30, 10),
|
FanCurvePoint(30, 10),
|
||||||
FanCurvePoint(40, 20),
|
FanCurvePoint(40, 20),
|
||||||
|
|
@ -233,6 +309,7 @@ class FanController:
|
||||||
if cpu_temps:
|
if cpu_temps:
|
||||||
max_temp = max(t.value for t in cpu_temps)
|
max_temp = max(t.value for t in cpu_temps)
|
||||||
else:
|
else:
|
||||||
|
# Fall back to highest overall temp
|
||||||
max_temp = max(t.value for t in temps)
|
max_temp = max(t.value for t in temps)
|
||||||
|
|
||||||
# Calculate target speed
|
# Calculate target speed
|
||||||
|
|
@ -240,20 +317,44 @@ class FanController:
|
||||||
|
|
||||||
# Enable manual control if not already
|
# Enable manual control if not already
|
||||||
if not server.manual_control_enabled:
|
if not server.manual_control_enabled:
|
||||||
if await asyncio.wait_for(
|
if client.enable_manual_fan_control():
|
||||||
asyncio.to_thread(client.enable_manual_fan_control),
|
|
||||||
timeout=10.0
|
|
||||||
):
|
|
||||||
server.manual_control_enabled = True
|
server.manual_control_enabled = True
|
||||||
logger.info(f"Enabled manual fan control for {server.name}")
|
logger.info(f"Enabled manual fan control for {server.name}")
|
||||||
|
|
||||||
# Set fan speed
|
# Set fan speed
|
||||||
if await asyncio.wait_for(
|
current_fans = client.get_fan_speeds()
|
||||||
asyncio.to_thread(client.set_all_fans_speed, target_speed),
|
avg_current_speed = 0
|
||||||
timeout=10.0
|
if current_fans:
|
||||||
):
|
# Estimate current speed from RPM if possible
|
||||||
|
avg_current_speed = 50 # Default assumption
|
||||||
|
|
||||||
|
# Only update if speed changed significantly (avoid constant small changes)
|
||||||
|
if abs(target_speed - avg_current_speed) >= 5:
|
||||||
|
if client.set_all_fans_speed(target_speed):
|
||||||
logger.info(f"Set {server.name} fans to {target_speed}% (temp: {max_temp}°C)")
|
logger.info(f"Set {server.name} fans to {target_speed}% (temp: {max_temp}°C)")
|
||||||
|
|
||||||
|
async def _handle_connection_loss(self, db: Session, server: Server):
|
||||||
|
"""Handle connection loss to a server."""
|
||||||
|
logger.warning(f"Connection lost to server {server.name}")
|
||||||
|
|
||||||
|
# Check if we should panic
|
||||||
|
server_id = server.id
|
||||||
|
last_seen = self._last_sensor_data.get(server_id)
|
||||||
|
|
||||||
|
if last_seen:
|
||||||
|
timeout = server.panic_timeout_seconds or settings.PANIC_TIMEOUT_SECONDS
|
||||||
|
elapsed = (datetime.utcnow() - last_seen).total_seconds()
|
||||||
|
|
||||||
|
if elapsed > timeout and server.panic_mode_enabled:
|
||||||
|
log = SystemLog(
|
||||||
|
server_id=server.id,
|
||||||
|
event_type="error",
|
||||||
|
message=f"Connection lost to server",
|
||||||
|
details=f"Last seen {elapsed:.0f} seconds ago"
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def get_controller_status(self, server_id: int) -> Dict[str, Any]:
|
def get_controller_status(self, server_id: int) -> Dict[str, Any]:
|
||||||
"""Get current controller status for a server."""
|
"""Get current controller status for a server."""
|
||||||
is_running = server_id in self._tasks
|
is_running = server_id in self._tasks
|
||||||
|
|
@ -266,264 +367,15 @@ class FanController:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SensorCollector:
|
|
||||||
"""High-performance background sensor data collector.
|
|
||||||
|
|
||||||
- Collects from all servers in parallel using thread pool
|
|
||||||
- Times out slow operations to prevent hanging
|
|
||||||
- Cleans up old database records periodically
|
|
||||||
- Updates cache for fast web UI access
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, max_workers: int = 4):
|
|
||||||
self.running = False
|
|
||||||
self._task: Optional[asyncio.Task] = None
|
|
||||||
self._collection_interval = 30 # seconds - IPMI is slow, need more time
|
|
||||||
self._cleanup_interval = 3600 # 1 hour
|
|
||||||
self._cache = None
|
|
||||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
||||||
self._last_cleanup = datetime.utcnow()
|
|
||||||
self._first_collection_done = False
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Start the sensor collector."""
|
|
||||||
self.running = True
|
|
||||||
self._task = asyncio.create_task(self._collection_loop())
|
|
||||||
logger.info("Sensor collector started")
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Stop the sensor collector."""
|
|
||||||
self.running = False
|
|
||||||
if self._task:
|
|
||||||
self._task.cancel()
|
|
||||||
try:
|
|
||||||
await self._task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._task = None
|
|
||||||
self._executor.shutdown(wait=False)
|
|
||||||
logger.info("Sensor collector stopped")
|
|
||||||
|
|
||||||
async def _collection_loop(self):
|
|
||||||
"""Main collection loop."""
|
|
||||||
# Initial collection immediately on startup
|
|
||||||
try:
|
|
||||||
logger.info("Performing initial sensor collection...")
|
|
||||||
await self._collect_all_servers()
|
|
||||||
self._first_collection_done = True
|
|
||||||
logger.info("Initial sensor collection complete")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Initial collection error: {e}")
|
|
||||||
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
start_time = datetime.utcnow()
|
|
||||||
await self._collect_all_servers()
|
|
||||||
|
|
||||||
# Periodic database cleanup
|
|
||||||
if (datetime.utcnow() - self._last_cleanup).total_seconds() > self._cleanup_interval:
|
|
||||||
await self._cleanup_old_data()
|
|
||||||
|
|
||||||
# Calculate sleep time to maintain interval
|
|
||||||
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
|
||||||
sleep_time = max(0, self._collection_interval - elapsed)
|
|
||||||
|
|
||||||
# Only warn if significantly over (collections can be slow)
|
|
||||||
if elapsed > self._collection_interval * 1.5:
|
|
||||||
logger.warning(f"Collection took {elapsed:.1f}s, longer than interval {self._collection_interval}s")
|
|
||||||
|
|
||||||
await asyncio.sleep(sleep_time)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Sensor collection error: {e}")
|
|
||||||
await asyncio.sleep(self._collection_interval)
|
|
||||||
|
|
||||||
async def _collect_all_servers(self):
|
|
||||||
"""Collect sensor data from all active servers in parallel."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
servers = db.query(Server).filter(Server.is_active == True).all()
|
|
||||||
if not servers:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create tasks for parallel collection
|
|
||||||
tasks = []
|
|
||||||
for server in servers:
|
|
||||||
task = self._collect_server_with_timeout(server)
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
# Run all collections concurrently with timeout protection
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# Process results and batch store in database
|
|
||||||
all_sensor_data = []
|
|
||||||
all_fan_data = []
|
|
||||||
|
|
||||||
for server, result in zip(servers, results):
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
logger.debug(f"Server {server.name} collection failed: {result}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result:
|
|
||||||
temps, fans = result
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
# Prepare batch inserts
|
|
||||||
for temp in temps:
|
|
||||||
all_sensor_data.append({
|
|
||||||
'server_id': server.id,
|
|
||||||
'sensor_name': temp.name,
|
|
||||||
'sensor_type': 'temperature',
|
|
||||||
'value': temp.value,
|
|
||||||
'unit': '°C',
|
|
||||||
'timestamp': now
|
|
||||||
})
|
|
||||||
|
|
||||||
for fan in fans:
|
|
||||||
all_fan_data.append({
|
|
||||||
'server_id': server.id,
|
|
||||||
'fan_number': fan.fan_number,
|
|
||||||
'fan_id': getattr(fan, 'fan_id', str(fan.fan_number)),
|
|
||||||
'speed_rpm': fan.speed_rpm,
|
|
||||||
'speed_percent': fan.speed_percent,
|
|
||||||
'timestamp': now
|
|
||||||
})
|
|
||||||
|
|
||||||
server.last_seen = now
|
|
||||||
|
|
||||||
# Batch insert for better performance
|
|
||||||
if all_sensor_data:
|
|
||||||
db.bulk_insert_mappings(SensorData, all_sensor_data)
|
|
||||||
if all_fan_data:
|
|
||||||
db.bulk_insert_mappings(FanData, all_fan_data)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
logger.debug(f"Collected data from {len([r for r in results if not isinstance(r, Exception)])}/{len(servers)} servers")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
async def _collect_server_with_timeout(self, server: Server) -> Optional[tuple]:
|
|
||||||
"""Collect sensor data from a single server with timeout protection."""
|
|
||||||
try:
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
self._collect_server(server),
|
|
||||||
timeout=30.0 # Max 30 seconds per server (IPMI can be slow)
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"Collection timeout for {server.name}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _collect_server(self, server: Server) -> Optional[tuple]:
|
|
||||||
"""Collect sensor data from a single server."""
|
|
||||||
try:
|
|
||||||
from backend.auth import decrypt_password
|
|
||||||
from backend.main import sensor_cache
|
|
||||||
|
|
||||||
# Run blocking IPMI operations in thread pool
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
client = IPMIClient(
|
|
||||||
host=server.ipmi_host,
|
|
||||||
username=server.ipmi_username,
|
|
||||||
password=decrypt_password(server.ipmi_encrypted_password),
|
|
||||||
port=server.ipmi_port,
|
|
||||||
vendor=server.vendor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test connection
|
|
||||||
connected = await loop.run_in_executor(self._executor, client.test_connection)
|
|
||||||
if not connected:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get sensor data in parallel using thread pool
|
|
||||||
temps_future = loop.run_in_executor(self._executor, client.get_temperatures)
|
|
||||||
fans_future = loop.run_in_executor(self._executor, client.get_fan_speeds)
|
|
||||||
power_future = loop.run_in_executor(self._executor, client.get_power_consumption)
|
|
||||||
|
|
||||||
temps, fans, power = await asyncio.gather(
|
|
||||||
temps_future, fans_future, power_future
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate summary metrics
|
|
||||||
max_temp = max((t.value for t in temps if t.value is not None), default=0)
|
|
||||||
avg_fan = sum(f.speed_percent for f in fans if f.speed_percent is not None) / len(fans) if fans else 0
|
|
||||||
|
|
||||||
# Extract current power consumption
|
|
||||||
current_power = None
|
|
||||||
if power and isinstance(power, dict):
|
|
||||||
import re
|
|
||||||
for key, value in power.items():
|
|
||||||
if 'current' in key.lower() and 'power' in key.lower():
|
|
||||||
match = re.search(r'(\d+(?:\.\d+)?)', str(value))
|
|
||||||
if match:
|
|
||||||
current_power = float(match.group(1))
|
|
||||||
break
|
|
||||||
|
|
||||||
# Prepare cache data - format must match response schemas
|
|
||||||
cache_data = {
|
|
||||||
"max_temp": max_temp,
|
|
||||||
"avg_fan_speed": round(avg_fan, 1),
|
|
||||||
"power_consumption": current_power,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"temps": [{"name": t.name, "value": t.value, "location": t.location, "status": getattr(t, 'status', 'ok')} for t in temps],
|
|
||||||
"fans": [{"fan_id": getattr(f, 'fan_id', f'0x0{f.fan_number-1}'), "fan_number": f.fan_number, "speed_percent": f.speed_percent, "speed_rpm": f.speed_rpm} for f in fans],
|
|
||||||
"power_raw": power if isinstance(power, dict) else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store in cache
|
|
||||||
await sensor_cache.set(server.id, cache_data)
|
|
||||||
|
|
||||||
logger.info(f"Collected and cached sensors for {server.name}: temp={max_temp:.1f}°C, fan={avg_fan:.1f}%")
|
|
||||||
return temps, fans
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to collect sensors for {server.name}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _cleanup_old_data(self):
|
|
||||||
"""Clean up old sensor data to prevent database bloat."""
|
|
||||||
try:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Keep only last 24 hours of detailed sensor data
|
|
||||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
|
||||||
|
|
||||||
# Delete old sensor data
|
|
||||||
deleted_sensors = db.query(SensorData).filter(
|
|
||||||
SensorData.timestamp < cutoff
|
|
||||||
).delete(synchronize_session=False)
|
|
||||||
|
|
||||||
# Delete old fan data
|
|
||||||
deleted_fans = db.query(FanData).filter(
|
|
||||||
FanData.timestamp < cutoff
|
|
||||||
).delete(synchronize_session=False)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
if deleted_sensors > 0 or deleted_fans > 0:
|
|
||||||
logger.info(f"Cleaned up {deleted_sensors} sensor records and {deleted_fans} fan records")
|
|
||||||
|
|
||||||
self._last_cleanup = datetime.utcnow()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Database cleanup failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Global controller instance
|
# Global controller instance
|
||||||
fan_controller = FanController()
|
fan_controller = FanController()
|
||||||
sensor_collector = SensorCollector(max_workers=4)
|
|
||||||
|
|
||||||
|
|
||||||
async def initialize_fan_controller():
|
async def initialize_fan_controller():
|
||||||
"""Initialize and start the fan controller and sensor collector."""
|
"""Initialize and start the fan controller."""
|
||||||
await sensor_collector.start()
|
|
||||||
await fan_controller.start()
|
await fan_controller.start()
|
||||||
|
|
||||||
|
|
||||||
async def shutdown_fan_controller():
|
async def shutdown_fan_controller():
|
||||||
"""Shutdown the fan controller and sensor collector."""
|
"""Shutdown the fan controller."""
|
||||||
await fan_controller.stop()
|
await fan_controller.stop()
|
||||||
await sensor_collector.stop()
|
|
||||||
|
|
|
||||||
175
backend/main.py
175
backend/main.py
|
|
@ -1,10 +1,8 @@
|
||||||
"""Main FastAPI application."""
|
"""Main FastAPI application."""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks
|
from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -182,6 +180,7 @@ async def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
|
from datetime import datetime
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -354,30 +353,11 @@ async def get_server_sensors(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get current sensor readings from server.
|
"""Get current sensor readings from server."""
|
||||||
|
|
||||||
Uses cached data from the continuous sensor collector for fast response.
|
|
||||||
Cache is updated every 10 seconds.
|
|
||||||
"""
|
|
||||||
server = db.query(Server).filter(Server.id == server_id).first()
|
server = db.query(Server).filter(Server.id == server_id).first()
|
||||||
if not server:
|
if not server:
|
||||||
raise HTTPException(status_code=404, detail="Server not found")
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
|
||||||
# Try cache first
|
|
||||||
cached = await sensor_cache.get(server_id)
|
|
||||||
if cached:
|
|
||||||
logger.info(f"Serving sensors for {server.name} from cache")
|
|
||||||
# Data is already in correct format from collector
|
|
||||||
return {
|
|
||||||
"server_id": server_id,
|
|
||||||
"temperatures": cached.get("temps", []),
|
|
||||||
"fans": cached.get("fans", []),
|
|
||||||
"all_sensors": [],
|
|
||||||
"timestamp": cached.get("timestamp", datetime.utcnow().isoformat())
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache miss - fetch live data
|
|
||||||
logger.warning(f"Cache miss for sensors {server.name}, fetching live")
|
|
||||||
try:
|
try:
|
||||||
client = IPMIClient(
|
client = IPMIClient(
|
||||||
host=server.ipmi_host,
|
host=server.ipmi_host,
|
||||||
|
|
@ -391,6 +371,7 @@ async def get_server_sensors(
|
||||||
fans = client.get_fan_speeds()
|
fans = client.get_fan_speeds()
|
||||||
all_sensors = client.get_all_sensors()
|
all_sensors = client.get_all_sensors()
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
return {
|
return {
|
||||||
"server_id": server_id,
|
"server_id": server_id,
|
||||||
"temperatures": [t.__dict__ for t in temps],
|
"temperatures": [t.__dict__ for t in temps],
|
||||||
|
|
@ -409,21 +390,11 @@ async def get_server_power(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get power consumption data from cache.
|
"""Get power consumption data."""
|
||||||
|
|
||||||
Data is updated every 10 seconds by the sensor collector.
|
|
||||||
"""
|
|
||||||
server = db.query(Server).filter(Server.id == server_id).first()
|
server = db.query(Server).filter(Server.id == server_id).first()
|
||||||
if not server:
|
if not server:
|
||||||
raise HTTPException(status_code=404, detail="Server not found")
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
|
||||||
# Try cache first
|
|
||||||
cached = await sensor_cache.get(server_id)
|
|
||||||
if cached and cached.get("power_raw"):
|
|
||||||
return cached["power_raw"]
|
|
||||||
|
|
||||||
# Cache miss - fetch live
|
|
||||||
logger.warning(f"Cache miss for power {server.name}, fetching live")
|
|
||||||
try:
|
try:
|
||||||
client = IPMIClient(
|
client = IPMIClient(
|
||||||
host=server.ipmi_host,
|
host=server.ipmi_host,
|
||||||
|
|
@ -739,41 +710,6 @@ async def disable_auto_control(
|
||||||
return {"success": True, "message": "Automatic fan control disabled"}
|
return {"success": True, "message": "Automatic fan control disabled"}
|
||||||
|
|
||||||
|
|
||||||
# Sensor data cache with TTL
|
|
||||||
|
|
||||||
class SensorCache:
|
|
||||||
"""Simple TTL cache for sensor data to reduce IPMI/SSH overhead."""
|
|
||||||
|
|
||||||
def __init__(self, ttl_seconds: int = 45):
|
|
||||||
self._cache: Dict[int, Dict[str, Any]] = {}
|
|
||||||
self._ttl = ttl_seconds
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def get(self, server_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
async with self._lock:
|
|
||||||
entry = self._cache.get(server_id)
|
|
||||||
if entry:
|
|
||||||
if datetime.utcnow() < entry['expires_at']:
|
|
||||||
return entry['data']
|
|
||||||
else:
|
|
||||||
del self._cache[server_id]
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def set(self, server_id: int, data: Dict[str, Any]):
|
|
||||||
async with self._lock:
|
|
||||||
self._cache[server_id] = {
|
|
||||||
'data': data,
|
|
||||||
'expires_at': datetime.utcnow() + timedelta(seconds=self._ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def invalidate(self, server_id: int):
|
|
||||||
async with self._lock:
|
|
||||||
self._cache.pop(server_id, None)
|
|
||||||
|
|
||||||
# Global sensor cache
|
|
||||||
sensor_cache = SensorCache(ttl_seconds=10)
|
|
||||||
|
|
||||||
|
|
||||||
# Dashboard endpoints
|
# Dashboard endpoints
|
||||||
@app.get("/api/dashboard/stats", response_model=DashboardStats)
|
@app.get("/api/dashboard/stats", response_model=DashboardStats)
|
||||||
async def get_dashboard_stats(
|
async def get_dashboard_stats(
|
||||||
|
|
@ -795,7 +731,7 @@ async def get_dashboard_stats(
|
||||||
if status.get("state") == "panic":
|
if status.get("state") == "panic":
|
||||||
panic_servers += 1
|
panic_servers += 1
|
||||||
|
|
||||||
# Get recent logs (use index on timestamp)
|
# Get recent logs
|
||||||
recent_logs = db.query(SystemLog).order_by(SystemLog.timestamp.desc()).limit(10).all()
|
recent_logs = db.query(SystemLog).order_by(SystemLog.timestamp.desc()).limit(10).all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -808,113 +744,22 @@ async def get_dashboard_stats(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/dashboard/servers-overview")
|
|
||||||
async def get_servers_overview(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get a lightweight overview of all servers for the dashboard grid.
|
|
||||||
|
|
||||||
Returns cached data from the continuous sensor collector.
|
|
||||||
Data is updated every 10 seconds automatically.
|
|
||||||
"""
|
|
||||||
servers = db.query(Server).all()
|
|
||||||
|
|
||||||
async def get_server_status(server: Server) -> Dict[str, Any]:
|
|
||||||
# Try cache first - sensor collector updates this every 30 seconds
|
|
||||||
cached = await sensor_cache.get(server.id)
|
|
||||||
if cached:
|
|
||||||
logger.debug(f"Serving overview for {server.name} from cache")
|
|
||||||
return {
|
|
||||||
"id": server.id,
|
|
||||||
"name": server.name,
|
|
||||||
"vendor": server.vendor,
|
|
||||||
"is_active": server.is_active,
|
|
||||||
"manual_control_enabled": server.manual_control_enabled,
|
|
||||||
"auto_control_enabled": server.auto_control_enabled,
|
|
||||||
"max_temp": cached.get("max_temp"),
|
|
||||||
"avg_fan_speed": cached.get("avg_fan_speed"),
|
|
||||||
"power_consumption": cached.get("power_consumption"),
|
|
||||||
"last_updated": cached.get("timestamp"),
|
|
||||||
"cached": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# No cache yet (sensor collector may not have run yet)
|
|
||||||
return {
|
|
||||||
"id": server.id,
|
|
||||||
"name": server.name,
|
|
||||||
"vendor": server.vendor,
|
|
||||||
"is_active": server.is_active,
|
|
||||||
"manual_control_enabled": server.manual_control_enabled,
|
|
||||||
"auto_control_enabled": server.auto_control_enabled,
|
|
||||||
"max_temp": None,
|
|
||||||
"avg_fan_speed": None,
|
|
||||||
"power_consumption": None,
|
|
||||||
"last_updated": None,
|
|
||||||
"cached": False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Gather all server statuses concurrently
|
|
||||||
server_statuses = await asyncio.gather(*[
|
|
||||||
get_server_status(server) for server in servers
|
|
||||||
])
|
|
||||||
|
|
||||||
return {"servers": server_statuses}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/dashboard/refresh-server/{server_id}")
|
|
||||||
async def refresh_server_data(
|
|
||||||
server_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Manually trigger a sensor data refresh for a server.
|
|
||||||
|
|
||||||
The sensor collector updates data every 10 seconds automatically.
|
|
||||||
This endpoint allows forcing an immediate refresh.
|
|
||||||
"""
|
|
||||||
server = db.query(Server).filter(Server.id == server_id).first()
|
|
||||||
if not server:
|
|
||||||
raise HTTPException(status_code=404, detail="Server not found")
|
|
||||||
|
|
||||||
# Trigger immediate collection via sensor_collector
|
|
||||||
from backend.fan_control import sensor_collector
|
|
||||||
await sensor_collector._collect_server_with_timeout(server)
|
|
||||||
|
|
||||||
return {"success": True, "message": "Data refreshed"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/dashboard/servers/{server_id}", response_model=ServerDashboardData)
|
@app.get("/api/dashboard/servers/{server_id}", response_model=ServerDashboardData)
|
||||||
async def get_server_dashboard(
|
async def get_server_dashboard(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get detailed dashboard data for a specific server.
|
"""Get detailed dashboard data for a specific server."""
|
||||||
|
|
||||||
Uses cached sensor data from the continuous collector.
|
|
||||||
Falls back to direct IPMI query only if cache is empty.
|
|
||||||
"""
|
|
||||||
server = db.query(Server).filter(Server.id == server_id).first()
|
server = db.query(Server).filter(Server.id == server_id).first()
|
||||||
if not server:
|
if not server:
|
||||||
raise HTTPException(status_code=404, detail="Server not found")
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
|
||||||
# Try to get sensor data from cache first
|
# Get current sensor data
|
||||||
cached = await sensor_cache.get(server_id)
|
|
||||||
|
|
||||||
temps = []
|
temps = []
|
||||||
fans = []
|
fans = []
|
||||||
power_data = None
|
power_data = None
|
||||||
|
|
||||||
if cached:
|
|
||||||
# Use cached data - already in correct format
|
|
||||||
temps = cached.get("temps", [])
|
|
||||||
fans = cached.get("fans", [])
|
|
||||||
power_data = cached.get("power_raw")
|
|
||||||
logger.info(f"Serving dashboard data for {server.name} from cache")
|
|
||||||
else:
|
|
||||||
# Cache miss - fetch live data as fallback
|
|
||||||
logger.warning(f"Cache miss for server {server.name}, fetching live data")
|
|
||||||
try:
|
try:
|
||||||
client = IPMIClient(
|
client = IPMIClient(
|
||||||
host=server.ipmi_host,
|
host=server.ipmi_host,
|
||||||
|
|
@ -926,9 +771,9 @@ async def get_server_dashboard(
|
||||||
|
|
||||||
if client.test_connection():
|
if client.test_connection():
|
||||||
temps_readings = client.get_temperatures()
|
temps_readings = client.get_temperatures()
|
||||||
temps = [{"name": t.name, "reading": t.value, "location": t.location} for t in temps_readings]
|
temps = [t.__dict__ for t in temps_readings]
|
||||||
fans_readings = client.get_fan_speeds()
|
fans_readings = client.get_fan_speeds()
|
||||||
fans = [{"fan_number": f.fan_number, "reading": f.speed_percent, "speed_rpm": f.speed_rpm} for f in fans_readings]
|
fans = [f.__dict__ for f in fans_readings]
|
||||||
power_data = client.get_power_consumption()
|
power_data = client.get_power_consumption()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not fetch live data for {server.name}: {e}")
|
logger.warning(f"Could not fetch live data for {server.name}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -164,25 +164,7 @@ class SSHClient:
|
||||||
package_temp = None
|
package_temp = None
|
||||||
|
|
||||||
for key, value in chip_data.items():
|
for key, value in chip_data.items():
|
||||||
# Skip metadata fields
|
if isinstance(value, (int, float)):
|
||||||
if key in ['Adapter']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle nested JSON structure from sensors -j
|
|
||||||
# e.g., "Core 0": {"temp2_input": 31, "temp2_max": 79, ...}
|
|
||||||
if isinstance(value, dict):
|
|
||||||
# Look for temp*_input field which contains the actual temperature
|
|
||||||
for sub_key, sub_value in value.items():
|
|
||||||
if 'input' in sub_key.lower() and isinstance(sub_value, (int, float)):
|
|
||||||
temp_value = float(sub_value)
|
|
||||||
if 'core' in key.lower():
|
|
||||||
core_temps[key] = temp_value
|
|
||||||
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
|
|
||||||
package_temp = temp_value
|
|
||||||
break # Only take the first _input value
|
|
||||||
|
|
||||||
# Handle flat structure (fallback for text parsing)
|
|
||||||
elif isinstance(value, (int, float)):
|
|
||||||
if 'core' in key.lower():
|
if 'core' in key.lower():
|
||||||
core_temps[key] = float(value)
|
core_temps[key] = float(value)
|
||||||
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
|
elif 'tdie' in key.lower() or 'tctl' in key.lower() or 'package' in key.lower():
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,541 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
Grid,
|
|
||||||
Button,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
ListItemButton,
|
|
||||||
IconButton,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Chip,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
Alert,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Tooltip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
Add as AddIcon,
|
|
||||||
Delete as DeleteIcon,
|
|
||||||
Edit as EditIcon,
|
|
||||||
PlayArrow as PlayIcon,
|
|
||||||
Stop as StopIcon,
|
|
||||||
ShowChart as ChartIcon,
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import { fanCurvesApi, fanControlApi } from '../utils/api';
|
|
||||||
import type { FanCurve, FanCurvePoint, Server } from '../types';
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
interface FanCurveManagerProps {
|
|
||||||
serverId: number;
|
|
||||||
server: Server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FanCurveManager({ serverId, server }: FanCurveManagerProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
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',
|
|
||||||
points: [
|
|
||||||
{ temp: 30, speed: 10 },
|
|
||||||
{ temp: 40, speed: 20 },
|
|
||||||
{ temp: 50, speed: 35 },
|
|
||||||
{ temp: 60, speed: 50 },
|
|
||||||
{ temp: 70, speed: 70 },
|
|
||||||
{ temp: 80, speed: 100 },
|
|
||||||
] as FanCurvePoint[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: curves, isLoading } = useQuery({
|
|
||||||
queryKey: ['fan-curves', serverId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fanCurvesApi.getAll(serverId);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: { name: string; curve_data: FanCurvePoint[]; sensor_source: string; is_active: boolean }) =>
|
|
||||||
fanCurvesApi.create(serverId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
|
||||||
handleCloseDialog();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
setError(error.response?.data?.detail || 'Failed to create fan curve');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
|
|
||||||
fanCurvesApi.update(serverId, curveId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
|
||||||
handleCloseDialog();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
setError(error.response?.data?.detail || 'Failed to update fan curve');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (curveId: number) => fanCurvesApi.delete(serverId, curveId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
|
|
||||||
if (selectedCurve?.id) {
|
|
||||||
setSelectedCurve(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const enableAutoMutation = useMutation({
|
|
||||||
mutationFn: (curveId: number) =>
|
|
||||||
fanControlApi.enableAuto(serverId, { enabled: true, curve_id: curveId }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableAutoMutation = useMutation({
|
|
||||||
mutationFn: () => fanControlApi.disableAuto(serverId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['server', serverId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenDialog = (curve?: FanCurve) => {
|
|
||||||
setError('');
|
|
||||||
if (curve) {
|
|
||||||
setEditingCurve(curve);
|
|
||||||
setFormData({
|
|
||||||
name: curve.name,
|
|
||||||
sensor_source: curve.sensor_source,
|
|
||||||
points: curve.curve_data,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setEditingCurve(null);
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
sensor_source: 'cpu',
|
|
||||||
points: [
|
|
||||||
{ temp: 30, speed: 10 },
|
|
||||||
{ temp: 40, speed: 20 },
|
|
||||||
{ temp: 50, speed: 35 },
|
|
||||||
{ temp: 60, speed: 50 },
|
|
||||||
{ temp: 70, speed: 70 },
|
|
||||||
{ temp: 80, speed: 100 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setOpenDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseDialog = () => {
|
|
||||||
setOpenDialog(false);
|
|
||||||
setEditingCurve(null);
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
setError('Curve name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.points.length < 2) {
|
|
||||||
setError('At least 2 points are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.trim(),
|
|
||||||
curve_data: formData.points,
|
|
||||||
sensor_source: formData.sensor_source,
|
|
||||||
is_active: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingCurve) {
|
|
||||||
updateMutation.mutate({ curveId: editingCurve.id, data });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePoint = (index: number, field: keyof FanCurvePoint, value: number) => {
|
|
||||||
const newPoints = [...formData.points];
|
|
||||||
newPoints[index] = { ...newPoints[index], [field]: value };
|
|
||||||
setFormData({ ...formData, points: newPoints });
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPoint = () => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
points: [...formData.points, { temp: 50, speed: 50 }],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePoint = (index: number) => {
|
|
||||||
if (formData.points.length > 2) {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
points: formData.points.filter((_, i) => i !== index),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActiveCurve = (curve: FanCurve) => {
|
|
||||||
return server.auto_control_enabled && selectedCurve?.id === curve.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6">
|
|
||||||
<ChartIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
|
||||||
Fan Curves
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
{server.auto_control_enabled ? (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
size="small"
|
|
||||||
startIcon={<StopIcon />}
|
|
||||||
onClick={() => disableAutoMutation.mutate()}
|
|
||||||
>
|
|
||||||
Stop Auto
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<PlayIcon />}
|
|
||||||
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
|
|
||||||
disabled={!selectedCurve}
|
|
||||||
>
|
|
||||||
Start Auto
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
onClick={() => handleOpenDialog()}
|
|
||||||
>
|
|
||||||
New Curve
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{server.auto_control_enabled && (
|
|
||||||
<Alert severity="success" sx={{ mb: 2 }}>
|
|
||||||
Automatic fan control is active
|
|
||||||
{selectedCurve && ` - Using "${selectedCurve.name}"`}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{/* Curve List */}
|
|
||||||
<Grid item xs={12} md={5}>
|
|
||||||
<Paper variant="outlined">
|
|
||||||
<List dense>
|
|
||||||
{isLoading ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary="Loading..." />
|
|
||||||
</ListItem>
|
|
||||||
) : curves?.length === 0 ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary="No fan curves"
|
|
||||||
secondary="Create a curve to enable automatic fan control"
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
curves?.map((curve) => (
|
|
||||||
<ListItem
|
|
||||||
key={curve.id}
|
|
||||||
secondaryAction={
|
|
||||||
<Box>
|
|
||||||
<Tooltip title="Edit">
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleOpenDialog(curve);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (confirm('Delete this fan curve?')) {
|
|
||||||
deleteMutation.mutate(curve.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
disablePadding
|
|
||||||
>
|
|
||||||
<ListItemButton
|
|
||||||
selected={selectedCurve?.id === curve.id}
|
|
||||||
onClick={() => setSelectedCurve(curve)}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{curve.name}
|
|
||||||
{isActiveCurve(curve) && (
|
|
||||||
<Chip size="small" color="success" label="Active" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
|
||||||
<Chip size="small" label={curve.sensor_source} variant="outlined" />
|
|
||||||
<Chip size="small" label={`${curve.curve_data.length} points`} variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Curve Preview */}
|
|
||||||
<Grid item xs={12} md={7}>
|
|
||||||
<Paper variant="outlined" sx={{ p: 2, height: 280 }}>
|
|
||||||
{selectedCurve ? (
|
|
||||||
<>
|
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
|
||||||
{selectedCurve.name}
|
|
||||||
</Typography>
|
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
|
||||||
<LineChart
|
|
||||||
data={selectedCurve.curve_data.map((p) => ({
|
|
||||||
...p,
|
|
||||||
label: `${p.temp}°C`,
|
|
||||||
}))}
|
|
||||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="temp"
|
|
||||||
label={{ value: 'Temp (°C)', position: 'insideBottom', offset: -5 }}
|
|
||||||
type="number"
|
|
||||||
domain={[0, 'dataMax + 10']}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
label={{ value: 'Fan %', angle: -90, position: 'insideLeft' }}
|
|
||||||
domain={[0, 100]}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
formatter={(value: number, name: string) => [
|
|
||||||
name === 'speed' ? `${value}%` : `${value}°C`,
|
|
||||||
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="speed"
|
|
||||||
stroke="#8884d8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 5 }}
|
|
||||||
activeDot={{ r: 7 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
Select a fan curve to preview
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
|
||||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
|
||||||
<DialogTitle>
|
|
||||||
{editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Curve Name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
margin="normal"
|
|
||||||
required
|
|
||||||
error={!formData.name && !!error}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth margin="normal">
|
|
||||||
<InputLabel>Sensor Source</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={formData.sensor_source}
|
|
||||||
label="Sensor Source"
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, sensor_source: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem value="cpu">CPU Temperature</MenuItem>
|
|
||||||
<MenuItem value="inlet">Inlet/Ambient Temperature</MenuItem>
|
|
||||||
<MenuItem value="exhaust">Exhaust Temperature</MenuItem>
|
|
||||||
<MenuItem value="highest">Highest Temperature</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
|
|
||||||
Curve Points
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{formData.points.map((point, index) => (
|
|
||||||
<Grid item xs={12} key={index}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="Temperature (°C)"
|
|
||||||
value={point.temp}
|
|
||||||
onChange={(e) =>
|
|
||||||
updatePoint(index, 'temp', parseInt(e.target.value) || 0)
|
|
||||||
}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
inputProps={{ min: 0, max: 150 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="Fan Speed (%)"
|
|
||||||
value={point.speed}
|
|
||||||
onChange={(e) =>
|
|
||||||
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
|
|
||||||
}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
inputProps={{ min: 0, max: 100 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => removePoint(index)}
|
|
||||||
disabled={formData.points.length <= 2}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={addPoint}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
>
|
|
||||||
Add Point
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Preview Chart */}
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
Preview
|
|
||||||
</Typography>
|
|
||||||
<Paper variant="outlined" sx={{ p: 2, height: 200 }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart
|
|
||||||
data={formData.points}
|
|
||||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="temp" type="number" domain={[0, 'dataMax + 10']} />
|
|
||||||
<YAxis domain={[0, 100]} />
|
|
||||||
<ChartTooltip
|
|
||||||
formatter={(value: number, name: string) => [
|
|
||||||
name === 'speed' ? `${value}%` : `${value}°C`,
|
|
||||||
name === 'speed' ? 'Fan Speed' : 'Temperature',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="speed"
|
|
||||||
stroke="#8884d8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 5 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Paper>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={createMutation.isPending || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{editingCurve ? 'Update' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,7 +14,7 @@ import {
|
||||||
Chip,
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Skeleton,
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Dns as ServerIcon,
|
Dns as ServerIcon,
|
||||||
|
|
@ -24,67 +23,21 @@ import {
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
CheckCircle as CheckIcon,
|
CheckCircle as CheckIcon,
|
||||||
Thermostat as TempIcon,
|
Thermostat as TempIcon,
|
||||||
Refresh as RefreshIcon,
|
NavigateNext as NextIcon,
|
||||||
PowerSettingsNew as PowerIcon,
|
|
||||||
Memory as MemoryIcon,
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { dashboardApi } from '../utils/api';
|
import { dashboardApi } from '../utils/api';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
interface ServerOverview {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
vendor: string;
|
|
||||||
is_active: boolean;
|
|
||||||
manual_control_enabled: boolean;
|
|
||||||
auto_control_enabled: boolean;
|
|
||||||
max_temp: number | null;
|
|
||||||
avg_fan_speed: number | null;
|
|
||||||
power_consumption: number | null;
|
|
||||||
last_updated: string | null;
|
|
||||||
cached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Stats query - poll every 60 seconds (stats don't change often)
|
const { data: stats, isLoading } = useQuery({
|
||||||
const { data: stats } = useQuery({
|
|
||||||
queryKey: ['dashboard-stats'],
|
queryKey: ['dashboard-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await dashboardApi.getStats();
|
const response = await dashboardApi.getStats();
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
refetchInterval: 60000, // 60 seconds
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
staleTime: 55000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server overview query - poll every 30 seconds (matches sensor collector)
|
|
||||||
const { data: overviewData, isLoading: overviewLoading } = useQuery({
|
|
||||||
queryKey: ['servers-overview'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await dashboardApi.getServersOverview();
|
|
||||||
return response.data.servers as ServerOverview[];
|
|
||||||
},
|
|
||||||
refetchInterval: 30000, // 30 seconds - matches sensor collector
|
|
||||||
staleTime: 25000,
|
|
||||||
// Don't refetch on window focus to reduce load
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Background refresh mutation
|
|
||||||
const refreshMutation = useMutation({
|
|
||||||
mutationFn: async (serverId: number) => {
|
|
||||||
const response = await dashboardApi.refreshServer(serverId);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate overview after a short delay to allow background fetch
|
|
||||||
setTimeout(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers-overview'] });
|
|
||||||
}, 2000);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getEventIcon = (eventType: string) => {
|
const getEventIcon = (eventType: string) => {
|
||||||
|
|
@ -126,157 +79,13 @@ export default function Dashboard() {
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ServerCard = ({ server }: { server: ServerOverview }) => {
|
if (isLoading) {
|
||||||
const hasData = server.max_temp !== null || server.avg_fan_speed !== null;
|
|
||||||
const isLoading = !hasData && server.is_active;
|
|
||||||
|
|
||||||
const getTempColor = (temp: number | null) => {
|
|
||||||
if (temp === null) return 'text.secondary';
|
|
||||||
if (temp > 80) return 'error.main';
|
|
||||||
if (temp > 70) return 'warning.main';
|
|
||||||
return 'success.main';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusChip = () => {
|
|
||||||
if (!server.is_active) {
|
|
||||||
return <Chip size="small" label="Offline" color="default" icon={<PowerIcon />} />;
|
|
||||||
}
|
|
||||||
if (server.manual_control_enabled) {
|
|
||||||
return <Chip size="small" label="Manual" color="info" icon={<SpeedIcon />} />;
|
|
||||||
}
|
|
||||||
if (server.auto_control_enabled) {
|
|
||||||
return <Chip size="small" label="Auto" color="success" icon={<CheckIcon />} />;
|
|
||||||
}
|
|
||||||
return <Chip size="small" label="Active" color="success" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
variant="outlined"
|
<CircularProgress />
|
||||||
sx={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
opacity: isLoading ? 0.7 : 1,
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: 2,
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClick={() => navigate(`/servers/${server.id}`)}
|
|
||||||
>
|
|
||||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<ServerIcon color={server.is_active ? 'primary' : 'disabled'} />
|
|
||||||
<Typography variant="subtitle1" fontWeight="medium" noWrap sx={{ maxWidth: 150 }}>
|
|
||||||
{server.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
{getStatusChip()}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Metrics Grid - Always show values or -- placeholder */}
|
|
||||||
<Grid container spacing={1} sx={{ mb: 1 }}>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" color={getTempColor(server.max_temp)}>
|
|
||||||
{server.max_temp !== null ? `${Math.round(server.max_temp)}°C` : '--'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Max Temp
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" color="primary.main">
|
|
||||||
{server.avg_fan_speed !== null ? `${Math.round(server.avg_fan_speed)}%` : '--'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Avg Fan
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" color="text.primary">
|
|
||||||
{server.power_consumption !== null ? `${Math.round(server.power_consumption)}W` : '--'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Power
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{server.vendor || 'Unknown Vendor'}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
{isLoading ? (
|
|
||||||
<Chip size="small" label="Loading..." color="warning" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
|
|
||||||
) : server.cached ? (
|
|
||||||
<Chip size="small" label="Cached" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
|
|
||||||
) : null}
|
|
||||||
<Tooltip title="Refresh data">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
refreshMutation.mutate(server.id);
|
|
||||||
}}
|
|
||||||
disabled={refreshMutation.isPending}
|
|
||||||
>
|
|
||||||
<RefreshIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show placeholder cards while loading initial data
|
|
||||||
const ServersPlaceholderGrid = () => (
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} lg={3} key={i}>
|
|
||||||
<Card variant="outlined" sx={{ opacity: 0.5 }}>
|
|
||||||
<CardContent sx={{ p: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
||||||
<Skeleton variant="circular" width={24} height={24} />
|
|
||||||
<Skeleton variant="text" width="60%" />
|
|
||||||
</Box>
|
|
||||||
<Grid container spacing={1}>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
|
||||||
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
|
||||||
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={4}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
|
|
||||||
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -328,49 +137,6 @@ export default function Dashboard() {
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Servers Grid */}
|
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
||||||
<Typography variant="h6">
|
|
||||||
Server Overview
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={`${overviewData?.length || 0} servers`}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{overviewLoading ? (
|
|
||||||
<ServersPlaceholderGrid />
|
|
||||||
) : overviewData && overviewData.length > 0 ? (
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{overviewData.map((server) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} lg={3} key={server.id}>
|
|
||||||
<ServerCard server={server} />
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
||||||
<ServerIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
|
||||||
<Typography variant="h6" color="text.secondary">
|
|
||||||
No servers configured
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
Add your first server to start monitoring
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label="Add Server"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate('/servers')}
|
|
||||||
clickable
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Recent Logs */}
|
{/* Recent Logs */}
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
|
|
@ -385,7 +151,7 @@ export default function Dashboard() {
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<List dense>
|
<List dense>
|
||||||
{stats?.recent_logs?.slice(0, 10).map((log: any) => (
|
{stats?.recent_logs?.slice(0, 10).map((log) => (
|
||||||
<ListItem key={log.id}>
|
<ListItem key={log.id}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{getEventIcon(log.event_type)}
|
{getEventIcon(log.event_type)}
|
||||||
|
|
@ -411,30 +177,54 @@ export default function Dashboard() {
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Quick Actions
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 1, '&:last-child': { pb: 1 } }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">Manage Servers</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Add, edit, or remove servers
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="Go to Servers">
|
||||||
|
<IconButton onClick={() => navigate('/servers')}>
|
||||||
|
<NextIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 1, '&:last-child': { pb: 1 } }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">View Logs</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Check system events and history
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="Go to Logs">
|
||||||
|
<IconButton onClick={() => navigate('/logs')}>
|
||||||
|
<NextIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{ py: 1, '&:last-child': { pb: 1 } }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
About IPMI Fan Control
|
About IPMI Fan Control
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" paragraph>
|
<Typography variant="body2" color="text.secondary">
|
||||||
This application allows you to control fan speeds on Dell T710 and compatible servers
|
This application allows you to control fan speeds on Dell T710 and compatible servers
|
||||||
using IPMI commands. Features include:
|
using IPMI commands. Features include manual fan control, automatic fan curves based
|
||||||
|
on temperature, and safety panic mode.
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense>
|
</CardContent>
|
||||||
<ListItem>
|
</Card>
|
||||||
<ListItemIcon><SpeedIcon color="primary" fontSize="small" /></ListItemIcon>
|
</Box>
|
||||||
<ListItemText primary="Manual fan control with per-fan adjustment" />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemIcon><TempIcon color="primary" fontSize="small" /></ListItemIcon>
|
|
||||||
<ListItemText primary="Automatic fan curves based on temperature sensors" />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemIcon><MemoryIcon color="primary" fontSize="small" /></ListItemIcon>
|
|
||||||
<ListItemText primary="SSH-based CPU temperature monitoring" />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemIcon><ErrorIcon color="primary" fontSize="small" /></ListItemIcon>
|
|
||||||
<ListItemText primary="Safety panic mode for overheating protection" />
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import {
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { serversApi, fanControlApi, dashboardApi } from '../utils/api';
|
import { serversApi, fanControlApi, dashboardApi } from '../utils/api';
|
||||||
import FanCurveManager from '../components/FanCurveManager';
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|
@ -65,10 +64,6 @@ export default function ServerDetail() {
|
||||||
const response = await serversApi.getById(serverId);
|
const response = await serversApi.getById(serverId);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
// Server config rarely changes
|
|
||||||
refetchInterval: 60000,
|
|
||||||
staleTime: 55000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: sensors, refetch: refetchSensors } = useQuery({
|
const { data: sensors, refetch: refetchSensors } = useQuery({
|
||||||
|
|
@ -77,9 +72,7 @@ export default function ServerDetail() {
|
||||||
const response = await serversApi.getSensors(serverId);
|
const response = await serversApi.getSensors(serverId);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
refetchInterval: 30000, // 30 seconds - matches sensor collector
|
refetchInterval: 5000,
|
||||||
staleTime: 25000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get SSH sensors for core temps - use dedicated endpoint
|
// Get SSH sensors for core temps - use dedicated endpoint
|
||||||
|
|
@ -95,9 +88,7 @@ export default function ServerDetail() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!server?.use_ssh,
|
enabled: !!server?.use_ssh,
|
||||||
refetchInterval: 30000, // SSH is slow - refresh less frequently
|
refetchInterval: 10000, // Slower refresh for SSH
|
||||||
staleTime: 25000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: dashboardData } = useQuery({
|
const { data: dashboardData } = useQuery({
|
||||||
|
|
@ -106,9 +97,7 @@ export default function ServerDetail() {
|
||||||
const response = await dashboardApi.getServerData(serverId);
|
const response = await dashboardApi.getServerData(serverId);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
refetchInterval: 60000, // Historical data doesn't change often
|
refetchInterval: 10000,
|
||||||
staleTime: 55000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const enableManualMutation = useMutation({
|
const enableManualMutation = useMutation({
|
||||||
|
|
@ -316,38 +305,28 @@ export default function ServerDetail() {
|
||||||
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<PowerIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
Power Consumption
|
Power Consumption
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Handle numeric power value (from cache) */}
|
|
||||||
{typeof dashboardData.power_consumption === 'number' && (
|
|
||||||
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center', maxWidth: 300 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Current Power Consumption
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h3" color="primary.main" sx={{ mt: 1 }}>
|
|
||||||
{Math.round(dashboardData.power_consumption)}W
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Handle dictionary power data (from live IPMI) */}
|
|
||||||
{typeof dashboardData.power_consumption === 'object' && (
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{Object.entries(dashboardData.power_consumption)
|
{Object.entries(dashboardData.power_consumption)
|
||||||
.filter(([_, value]) => {
|
.filter(([_, value]) => !value.includes('UTC')) // Filter out weird timestamp entries
|
||||||
// Filter out empty values, timestamps, and metadata
|
.slice(0, 4)
|
||||||
if (!value || value === '') return false;
|
|
||||||
if (typeof value === 'string' && value.includes('UTC')) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
// Show the raw value as-is from IPMI
|
// Clean up the display
|
||||||
const displayValue = typeof value === 'string' ? value : String(value);
|
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 (
|
return (
|
||||||
<Grid item xs={6} md={3} key={key}>
|
<Grid item xs={6} md={3} key={key}>
|
||||||
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
|
||||||
{key}
|
{displayKey.replace(/_/g, ' ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ mt: 0.5 }}>
|
<Typography variant="h6" sx={{ mt: 0.5 }}>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
|
|
@ -357,7 +336,6 @@ export default function ServerDetail() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -506,11 +484,6 @@ export default function ServerDetail() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Fan Curves Section */}
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<FanCurveManager serverId={serverId} server={server} />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,22 +147,6 @@ export const fanCurvesApi = {
|
||||||
// Dashboard API
|
// Dashboard API
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
getStats: () => api.get<DashboardStats>('/dashboard/stats'),
|
getStats: () => api.get<DashboardStats>('/dashboard/stats'),
|
||||||
getServersOverview: () =>
|
|
||||||
api.get<{ servers: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
vendor: string;
|
|
||||||
is_active: boolean;
|
|
||||||
manual_control_enabled: boolean;
|
|
||||||
auto_control_enabled: boolean;
|
|
||||||
max_temp: number | null;
|
|
||||||
avg_fan_speed: number | null;
|
|
||||||
power_consumption: number | null;
|
|
||||||
last_updated: string | null;
|
|
||||||
cached: boolean;
|
|
||||||
}> }>('/dashboard/servers-overview'),
|
|
||||||
refreshServer: (serverId: number) =>
|
|
||||||
api.post<{ success: boolean; message: string }>(`/dashboard/refresh-server/${serverId}`),
|
|
||||||
getServerData: (serverId: number) =>
|
getServerData: (serverId: number) =>
|
||||||
api.get<{
|
api.get<{
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue