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
This commit is contained in:
ImpulsiveFPS 2026-02-01 22:19:52 +01:00
parent 505d19a439
commit 7c5b44539f
2 changed files with 604 additions and 0 deletions

63
backend/cache.py Normal file
View File

@ -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()

View File

@ -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<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>
);
}