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

444 lines
15 KiB
TypeScript

import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Grid,
Paper,
Typography,
Card,
CardContent,
List,
ListItem,
ListItemText,
ListItemIcon,
Chip,
IconButton,
Tooltip,
Skeleton,
} from '@mui/material';
import {
Dns as ServerIcon,
Speed as SpeedIcon,
Warning as WarningIcon,
Error as ErrorIcon,
CheckCircle as CheckIcon,
Thermostat as TempIcon,
Refresh as RefreshIcon,
PowerSettingsNew as PowerIcon,
Memory as MemoryIcon,
} from '@mui/icons-material';
import { dashboardApi } from '../utils/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface ServerOverview {
id: number;
name: string;
vendor: string;
is_active: boolean;
manual_control_enabled: boolean;
auto_control_enabled: boolean;
max_temp: number | null;
avg_fan_speed: number | null;
power_consumption: number | null;
last_updated: string | null;
cached: boolean;
}
export default function Dashboard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Stats query - poll every 60 seconds (stats don't change often)
const { data: stats } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: async () => {
const response = await dashboardApi.getStats();
return response.data;
},
refetchInterval: 60000, // 60 seconds
staleTime: 55000,
});
// Server overview query - poll every 30 seconds (matches sensor collector)
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['servers-overview'],
queryFn: async () => {
const response = await dashboardApi.getServersOverview();
return response.data.servers as ServerOverview[];
},
refetchInterval: 30000, // 30 seconds - matches sensor collector
staleTime: 25000,
// Don't refetch on window focus to reduce load
refetchOnWindowFocus: false,
});
// Background refresh mutation
const refreshMutation = useMutation({
mutationFn: async (serverId: number) => {
const response = await dashboardApi.refreshServer(serverId);
return response.data;
},
onSuccess: () => {
// Invalidate overview after a short delay to allow background fetch
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['servers-overview'] });
}, 2000);
},
});
const getEventIcon = (eventType: string) => {
switch (eventType) {
case 'panic':
return <ErrorIcon color="error" />;
case 'error':
return <WarningIcon color="warning" />;
case 'warning':
return <WarningIcon color="warning" />;
default:
return <CheckIcon color="success" />;
}
};
const StatCard = ({
title,
value,
icon,
color
}: {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}) => (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Box sx={{ color, mr: 1 }}>{icon}</Box>
<Typography variant="h6" component="div">
{value}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
</CardContent>
</Card>
);
const ServerCard = ({ server }: { server: ServerOverview }) => {
const hasData = server.max_temp !== null || server.avg_fan_speed !== null;
const isLoading = !hasData && server.is_active;
const getTempColor = (temp: number | null) => {
if (temp === null) return 'text.secondary';
if (temp > 80) return 'error.main';
if (temp > 70) return 'warning.main';
return 'success.main';
};
const getStatusChip = () => {
if (!server.is_active) {
return <Chip size="small" label="Offline" color="default" icon={<PowerIcon />} />;
}
if (server.manual_control_enabled) {
return <Chip size="small" label="Manual" color="info" icon={<SpeedIcon />} />;
}
if (server.auto_control_enabled) {
return <Chip size="small" label="Auto" color="success" icon={<CheckIcon />} />;
}
return <Chip size="small" label="Active" color="success" />;
};
return (
<Card
variant="outlined"
sx={{
cursor: 'pointer',
transition: 'all 0.2s',
opacity: isLoading ? 0.7 : 1,
'&:hover': {
boxShadow: 2,
borderColor: 'primary.main',
},
}}
onClick={() => navigate(`/servers/${server.id}`)}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ServerIcon color={server.is_active ? 'primary' : 'disabled'} />
<Typography variant="subtitle1" fontWeight="medium" noWrap sx={{ maxWidth: 150 }}>
{server.name}
</Typography>
</Box>
{getStatusChip()}
</Box>
{/* Metrics Grid - Always show values or -- placeholder */}
<Grid container spacing={1} sx={{ mb: 1 }}>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color={getTempColor(server.max_temp)}>
{server.max_temp !== null ? `${Math.round(server.max_temp)}°C` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Max Temp
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color="primary.main">
{server.avg_fan_speed !== null ? `${Math.round(server.avg_fan_speed)}%` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Avg Fan
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" color="text.primary">
{server.power_consumption !== null ? `${Math.round(server.power_consumption)}W` : '--'}
</Typography>
<Typography variant="caption" color="text.secondary">
Power
</Typography>
</Box>
</Grid>
</Grid>
{/* Footer */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<Typography variant="caption" color="text.secondary">
{server.vendor || 'Unknown Vendor'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isLoading ? (
<Chip size="small" label="Loading..." color="warning" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
) : server.cached ? (
<Chip size="small" label="Cached" variant="outlined" sx={{ height: 20, fontSize: '0.6rem' }} />
) : null}
<Tooltip title="Refresh data">
<IconButton
size="small"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
refreshMutation.mutate(server.id);
}}
disabled={refreshMutation.isPending}
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</CardContent>
</Card>
);
};
// Show placeholder cards while loading initial data
const ServersPlaceholderGrid = () => (
<Grid container spacing={2}>
{[1, 2, 3, 4].map((i) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={i}>
<Card variant="outlined" sx={{ opacity: 0.5 }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" width="60%" />
</Box>
<Grid container spacing={1}>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Skeleton variant="text" width={30} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width={40} sx={{ mx: 'auto' }} />
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Total Servers"
value={stats?.total_servers || 0}
icon={<ServerIcon />}
color="primary.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Active Servers"
value={stats?.active_servers || 0}
icon={<CheckIcon />}
color="success.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Manual Control"
value={stats?.manual_control_servers || 0}
icon={<SpeedIcon />}
color="info.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Auto Control"
value={stats?.auto_control_servers || 0}
icon={<TempIcon />}
color="warning.main"
/>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<StatCard
title="Panic Mode"
value={stats?.panic_mode_servers || 0}
icon={<ErrorIcon />}
color="error.main"
/>
</Grid>
</Grid>
{/* Servers Grid */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">
Server Overview
</Typography>
<Chip
label={`${overviewData?.length || 0} servers`}
size="small"
color="primary"
variant="outlined"
/>
</Box>
{overviewLoading ? (
<ServersPlaceholderGrid />
) : overviewData && overviewData.length > 0 ? (
<Grid container spacing={2}>
{overviewData.map((server) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={server.id}>
<ServerCard server={server} />
</Grid>
))}
</Grid>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<ServerIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
No servers configured
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Add your first server to start monitoring
</Typography>
<Chip
label="Add Server"
color="primary"
onClick={() => navigate('/servers')}
clickable
/>
</Box>
)}
</Paper>
{/* Recent Logs */}
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Recent Events</Typography>
<Chip
label="View All"
size="small"
onClick={() => navigate('/logs')}
clickable
/>
</Box>
<List dense>
{stats?.recent_logs?.slice(0, 10).map((log: any) => (
<ListItem key={log.id}>
<ListItemIcon>
{getEventIcon(log.event_type)}
</ListItemIcon>
<ListItemText
primary={log.message}
secondary={new Date(log.timestamp).toLocaleString()}
/>
</ListItem>
))}
{!stats?.recent_logs?.length && (
<ListItem>
<ListItemText
primary="No events yet"
secondary="Events will appear here when they occur"
/>
</ListItem>
)}
</List>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
About IPMI Fan Control
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This application allows you to control fan speeds on Dell T710 and compatible servers
using IPMI commands. Features include:
</Typography>
<List dense>
<ListItem>
<ListItemIcon><SpeedIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Manual fan control with per-fan adjustment" />
</ListItem>
<ListItem>
<ListItemIcon><TempIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Automatic fan curves based on temperature sensors" />
</ListItem>
<ListItem>
<ListItemIcon><MemoryIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="SSH-based CPU temperature monitoring" />
</ListItem>
<ListItem>
<ListItemIcon><ErrorIcon color="primary" fontSize="small" /></ListItemIcon>
<ListItemText primary="Safety panic mode for overheating protection" />
</ListItem>
</List>
</Paper>
</Grid>
</Grid>
</Box>
);
}