480 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|