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