From 7c5b44539fd575ebb7ab68d33c94ffe18e2cb675 Mon Sep 17 00:00:00 2001 From: ImpulsiveFPS Date: Sun, 1 Feb 2026 22:19:52 +0100 Subject: [PATCH] Performance optimizations and dashboard redesign - Add continuous sensor collector with parallel IPMI operations - Implement TTL cache for fast web UI responses - Add timeouts to prevent hanging on slow servers - Add automatic database cleanup for old sensor data - Optimize frontend polling intervals (30s/60s instead of 5s/10s) - Move fan curve management to Server Detail Fan Control tab - Fix SSH sensor parsing for nested JSON from lm-sensors - Fix power consumption display for Dell powermonitor output - Add server overview grid to dashboard with temp/fan/power metrics - Add database indexes for faster queries --- backend/cache.py | 63 +++ frontend/src/components/FanCurveManager.tsx | 541 ++++++++++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 backend/cache.py create mode 100644 frontend/src/components/FanCurveManager.tsx diff --git a/backend/cache.py b/backend/cache.py new file mode 100644 index 0000000..d5f22b0 --- /dev/null +++ b/backend/cache.py @@ -0,0 +1,63 @@ +"""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() diff --git a/frontend/src/components/FanCurveManager.tsx b/frontend/src/components/FanCurveManager.tsx new file mode 100644 index 0000000..6f85c39 --- /dev/null +++ b/frontend/src/components/FanCurveManager.tsx @@ -0,0 +1,541 @@ +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(null); + const [openDialog, setOpenDialog] = useState(false); + const [editingCurve, setEditingCurve] = useState(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 ( + + + + + + Fan Curves + + + {server.auto_control_enabled ? ( + + ) : ( + + )} + + + + + {server.auto_control_enabled && ( + + Automatic fan control is active + {selectedCurve && ` - Using "${selectedCurve.name}"`} + + )} + + + {/* Curve List */} + + + + {isLoading ? ( + + + + ) : curves?.length === 0 ? ( + + + + ) : ( + curves?.map((curve) => ( + + + { + e.stopPropagation(); + handleOpenDialog(curve); + }} + > + + + + + { + e.stopPropagation(); + if (confirm('Delete this fan curve?')) { + deleteMutation.mutate(curve.id); + } + }} + > + + + + + } + disablePadding + > + setSelectedCurve(curve)} + > + + {curve.name} + {isActiveCurve(curve) && ( + + )} + + } + secondary={ + + + + + } + /> + + + )) + )} + + + + + {/* Curve Preview */} + + + {selectedCurve ? ( + <> + + {selectedCurve.name} + + + ({ + ...p, + label: `${p.temp}°C`, + }))} + margin={{ top: 5, right: 20, left: 0, bottom: 5 }} + > + + + + [ + name === 'speed' ? `${value}%` : `${value}°C`, + name === 'speed' ? 'Fan Speed' : 'Temperature', + ]} + /> + + + + + ) : ( + + + Select a fan curve to preview + + + )} + + + + + {/* Create/Edit Dialog */} + + + {editingCurve ? 'Edit Fan Curve' : 'Create Fan Curve'} + + + {error && ( + + {error} + + )} + + setFormData({ ...formData, name: e.target.value })} + margin="normal" + required + error={!formData.name && !!error} + /> + + Sensor Source + + + + + Curve Points + + + + {formData.points.map((point, index) => ( + + + + updatePoint(index, 'temp', parseInt(e.target.value) || 0) + } + sx={{ flex: 1 }} + inputProps={{ min: 0, max: 150 }} + /> + + updatePoint(index, 'speed', parseInt(e.target.value) || 0) + } + sx={{ flex: 1 }} + inputProps={{ min: 0, max: 100 }} + /> + + + + ))} + + + + + {/* Preview Chart */} + + Preview + + + + + + + + [ + name === 'speed' ? `${value}%` : `${value}°C`, + name === 'speed' ? 'Fan Speed' : 'Temperature', + ]} + /> + + + + + + + + + + + + + ); +}