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:
parent
505d19a439
commit
7c5b44539f
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue