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