ipmi-fan-control/frontend/src/pages/FanCurves.tsx

480 lines
15 KiB
TypeScript

import { useState } from 'react';
import { useParams } from 'react-router-dom';
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,
Divider,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
PlayArrow as PlayIcon,
Stop as StopIcon,
} from '@mui/icons-material';
import { fanCurvesApi, fanControlApi, serversApi } from '../utils/api';
import type { FanCurve, FanCurvePoint } from '../types';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area } from 'recharts';
export default function FanCurves() {
const { id } = useParams<{ id: string }>();
const serverId = parseInt(id || '0');
const queryClient = useQueryClient();
const [selectedCurve, setSelectedCurve] = useState<FanCurve | null>(null);
const [openDialog, setOpenDialog] = useState(false);
const [editingCurve, setEditingCurve] = useState<FanCurve | null>(null);
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: server } = useQuery({
queryKey: ['server', serverId],
queryFn: async () => {
const response = await serversApi.getById(serverId);
return response.data;
},
});
const { data: curves } = 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();
},
});
const updateMutation = useMutation({
mutationFn: ({ curveId, data }: { curveId: number; data: any }) =>
fanCurvesApi.update(serverId, curveId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fan-curves', serverId] });
handleCloseDialog();
},
});
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) => {
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);
};
const handleSubmit = () => {
const data = {
name: formData.name,
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),
});
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4">Fan Curves</Typography>
<Typography variant="body2" color="text.secondary">
{server?.name}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{server?.auto_control_enabled ? (
<Button
variant="outlined"
color="error"
startIcon={<StopIcon />}
onClick={() => disableAutoMutation.mutate()}
>
Stop Auto Control
</Button>
) : (
<Button
variant="outlined"
startIcon={<PlayIcon />}
onClick={() => selectedCurve && enableAutoMutation.mutate(selectedCurve.id)}
disabled={!selectedCurve}
>
Start Auto Control
</Button>
)}
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
New Curve
</Button>
</Box>
</Box>
{server?.auto_control_enabled && (
<Alert severity="success" sx={{ mb: 3 }}>
Automatic fan control is currently active on this server.
</Alert>
)}
<Grid container spacing={3}>
{/* Curve List */}
<Grid item xs={12} md={4}>
<Paper>
<List>
{curves?.map((curve) => (
<ListItem
key={curve.id}
secondaryAction={
<Box>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
handleOpenDialog(curve);
}}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this fan curve?')) {
deleteMutation.mutate(curve.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
}
disablePadding
>
<ListItemButton
selected={selectedCurve?.id === curve.id}
onClick={() => setSelectedCurve(curve)}
>
<ListItemText
primary={curve.name}
secondary={
<Box component="span" sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Chip size="small" label={curve.sensor_source} />
{server?.auto_control_enabled && selectedCurve?.id === curve.id && (
<Chip size="small" color="success" label="Active" />
)}
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
{!curves?.length && (
<ListItem>
<ListItemText
primary="No fan curves yet"
secondary="Create a new curve to get started"
/>
</ListItem>
)}
</List>
</Paper>
</Grid>
{/* Curve Preview */}
<Grid item xs={12} md={8}>
<Paper sx={{ p: 3, height: 400 }}>
{selectedCurve ? (
<>
<Typography variant="h6" gutterBottom>
{selectedCurve.name}
</Typography>
<ResponsiveContainer width="100%" height="90%">
<LineChart
data={selectedCurve.curve_data.map((p) => ({
...p,
label: `${p.temp}°C`,
}))}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="temp"
label={{ value: 'Temperature (°C)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
label={{ value: 'Fan Speed (%)', angle: -90, position: 'insideLeft' }}
domain={[0, 100]}
/>
<Tooltip
formatter={(value: number, name: string) => [
name === 'speed' ? `${value}%` : `${value}°C`,
name === 'speed' ? 'Fan Speed' : 'Temperature',
]}
/>
<Area
type="monotone"
dataKey="speed"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.3}
/>
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 6 }}
activeDot={{ r: 8 }}
/>
</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>
<TextField
fullWidth
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
margin="normal"
/>
<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 }}
/>
<TextField
type="number"
label="Fan Speed (%)"
value={point.speed}
onChange={(e) =>
updatePoint(index, 'speed', parseInt(e.target.value) || 0)
}
inputProps={{ min: 0, max: 100 }}
sx={{ flex: 1 }}
/>
<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 }}>
Add Point
</Button>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Preview
</Typography>
<Box sx={{ height: 250 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={formData.points}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="temp" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={
!formData.name ||
createMutation.isPending ||
updateMutation.isPending
}
>
{editingCurve ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}