This commit is contained in:
courtmanr@gmail.com
2025-03-01 14:03:01 +00:00
parent 33cb2692c9
commit a897312ce7
4 changed files with 500 additions and 111 deletions
+6 -8
View File
@@ -2,15 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="System monitoring dashboard with real-time updates" />
<title>System Monitor</title>
<!-- Roboto font for Material UI -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<meta name="description" content="Pulse - Real-time system monitoring dashboard with live updates" />
<title>Pulse</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<!-- Gradient definitions to match the app logo -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.2)" />
<stop offset="100%" stop-color="rgba(255,255,255,0.1)" />
</linearGradient>
<linearGradient id="centerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="#f0f0f0" />
</linearGradient>
</defs>
<!-- Base background for better visibility -->
<circle cx="16" cy="16" r="16" fill="#3a7bd5" opacity="0.8" />
<!-- Background circle with gradient matching the logo -->
<circle cx="16" cy="16" r="15" fill="url(#bgGradient)" />
<!-- Pulse Animation Rings -->
<circle cx="16" cy="16" r="12" fill="none" stroke="rgba(255,255,255,0.4)" stroke-width="2">
<animate attributeName="r" values="8;14" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="1;0" dur="2s" repeatCount="indefinite" />
</circle>
<!-- Center dot with gradient matching the logo -->
<circle cx="16" cy="16" r="8" fill="url(#centerGradient)" />
<!-- Dark mode support -->
<style>
@media (prefers-color-scheme: dark) {
#bgGradient stop:first-child { stop-color: rgba(255,255,255,0.2); }
#bgGradient stop:last-child { stop-color: rgba(255,255,255,0.1); }
}
@media (prefers-color-scheme: light) {
#bgGradient stop:first-child { stop-color: rgba(255,255,255,0.2); }
#bgGradient stop:last-child { stop-color: rgba(255,255,255,0.1); }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+371 -61
View File
@@ -1,27 +1,128 @@
import React from 'react';
import { Container, Box, Typography, AppBar, Toolbar, Paper } from '@mui/material';
import React, { useState, useEffect } from 'react';
import {
Container,
Box,
Typography,
AppBar,
Toolbar,
Paper,
IconButton,
Tooltip,
alpha,
Select,
MenuItem,
FormControl,
InputLabel,
Chip,
ListItemIcon,
ListItemText,
Divider
} from '@mui/material';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import ComputerIcon from '@mui/icons-material/Computer';
import DnsIcon from '@mui/icons-material/Dns';
import ViewListIcon from '@mui/icons-material/ViewList';
import CheckIcon from '@mui/icons-material/Check';
import NetworkDisplay from './components/NetworkDisplay';
import { AppThemeProvider } from './context/ThemeContext';
import { AppThemeProvider, useThemeContext } from './context/ThemeContext';
import useSocket from './hooks/useSocket';
function App() {
function AppContent() {
const { darkMode, toggleDarkMode } = useThemeContext();
const [selectedNode, setSelectedNode] = useState('all');
const { nodeData, guestData } = useSocket();
// Transform the node data from the API into the format needed for the dropdown
const availableNodes = React.useMemo(() => {
// Start with the "All Nodes" option
const nodes = [
{ id: 'all', name: 'All Nodes', count: 0 }
];
// Add nodes from the API
if (nodeData && nodeData.length > 0) {
// Count guests for each node
const nodeCounts = {};
// Initialize counts for each node
nodeData.forEach(node => {
// Extract node number from the id (e.g., "node-1" -> "node1")
const nodeId = node.id.replace('-', '');
nodeCounts[nodeId] = 0;
});
// Count guests for each node
if (guestData && guestData.length > 0) {
guestData.forEach(guest => {
if (guest.node) {
// Convert "node-1" to "node1" format
const normalizedNodeId = guest.node.replace('-', '');
if (nodeCounts[normalizedNodeId] !== undefined) {
nodeCounts[normalizedNodeId]++;
}
}
});
}
// Add nodes to the list with their counts
nodeData.forEach(node => {
// Extract node number from the id (e.g., "node-1" -> "node1")
const nodeId = node.id.replace('-', '');
// Add the node to the list
nodes.push({
id: nodeId,
name: node.name,
count: nodeCounts[nodeId] || 0
});
});
// Update the count for "All Nodes"
nodes[0].count = guestData ? guestData.length : 0;
}
return nodes;
}, [nodeData, guestData]);
// Handle node selection change
const handleNodeChange = (event) => {
setSelectedNode(event.target.value);
};
return (
<AppThemeProvider>
<Box sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}>
<AppBar position="static" color="primary" elevation={0}>
<Toolbar sx={{ px: { xs: 2, sm: 3 } }}>
<Typography
variant="h6"
component="div"
<Box sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}>
<AppBar position="static" color="primary" elevation={0}>
<Toolbar sx={{ px: { xs: 2, sm: 3 } }}>
<Typography
variant="h6"
component="div"
sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 700,
letterSpacing: '0.03em',
}}
>
{/* Enhanced Logo - Made clickable to refresh page */}
<Box
onClick={() => {
window.location.reload();
// Reset to default state if needed
setSelectedNode('all');
}}
sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
letterSpacing: '0.02em',
cursor: 'pointer',
'&:hover': {
opacity: 0.9,
},
}}
>
<Box
@@ -31,60 +132,269 @@ function App() {
alignItems: 'center',
justifyContent: 'center',
mr: 1.5,
width: 32,
height: 32,
width: 34,
height: 34,
borderRadius: '50%',
bgcolor: 'rgba(255, 255, 255, 0.2)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%)',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Pulse Animation Rings */}
<Box
component="span"
sx={{
width: 20,
height: 20,
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.4)',
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%': {
transform: 'scale(0.5)',
opacity: 1,
},
'100%': {
transform: 'scale(1.2)',
opacity: 0,
},
},
}}
/>
<Box
component="span"
sx={{
width: 18,
height: 18,
borderRadius: '50%',
bgcolor: 'white',
boxShadow: '0 0 0 2px rgba(255, 255, 255, 0.5)',
background: 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)',
boxShadow: '0 0 0 2px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0,0,0,0.1)',
zIndex: 2,
}}
/>
</Box>
System Monitor
</Typography>
</Toolbar>
</AppBar>
<Container
maxWidth="lg"
sx={{
mt: { xs: 2, sm: 4 },
mb: { xs: 2, sm: 4 },
flexGrow: 1,
px: { xs: 2, sm: 3 },
}}
>
<Box sx={{ my: { xs: 2, sm: 4 } }}>
{/* Network Display Component */}
<NetworkDisplay />
</Box>
</Container>
<Paper
sx={{
padding: 2,
marginTop: 'auto',
borderRadius: 0,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
}}
component="footer"
elevation={0}
>
<Typography variant="body2" color="text.secondary" align="center">
System Monitor © {new Date().getFullYear()}
<Box sx={{ display: 'flex', alignItems: 'baseline' }}>
Pulse
<Typography
component="span"
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the parent onClick (page refresh)
window.open('https://github.com/rcourtman/pulse', '_blank');
}}
sx={{
ml: 1,
opacity: 0.8,
fontWeight: 400,
fontSize: '0.7rem',
bgcolor: 'rgba(255,255,255,0.15)',
px: 0.8,
py: 0.2,
borderRadius: 1,
letterSpacing: '0.02em',
cursor: 'pointer',
'&:hover': {
opacity: 1,
bgcolor: 'rgba(255,255,255,0.25)',
},
transition: 'all 0.2s ease'
}}
>
v1.0.10
</Typography>
</Box>
</Box>
</Typography>
</Paper>
</Box>
{/* Node Selection Dropdown */}
<FormControl
variant="outlined"
size="small"
sx={{
minWidth: { xs: 120, sm: 200 },
mr: 2,
'& .MuiOutlinedInput-root': {
color: 'white',
borderRadius: 2,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.15)',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255, 255, 255, 0.3)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255, 255, 255, 0.5)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255, 255, 255, 0.7)',
},
'& .MuiSelect-icon': {
color: 'rgba(255, 255, 255, 0.7)',
}
}
}}
>
<Select
value={selectedNode}
onChange={handleNodeChange}
displayEmpty
renderValue={(selected) => {
const node = availableNodes.find(n => n.id === selected);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{selected === 'all' ? (
<ViewListIcon fontSize="small" />
) : (
<DnsIcon fontSize="small" />
)}
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{node ? node.name : 'Select Node'}
</Typography>
{node && (
<Chip
label={node.count}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
ml: 'auto'
}}
/>
)}
</Box>
);
}}
MenuProps={{
PaperProps: {
sx: {
maxHeight: 300,
mt: 0.5,
borderRadius: 2,
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
'& .MuiMenuItem-root': {
py: 1,
px: 2,
}
}
}
}}
>
{availableNodes.map((node) => (
<MenuItem
key={node.id}
value={node.id}
sx={{
borderRadius: 1,
my: 0.5,
'&.Mui-selected': {
bgcolor: theme => alpha(theme.palette.primary.main, 0.1),
'&:hover': {
bgcolor: theme => alpha(theme.palette.primary.main, 0.15),
}
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{node.id === 'all' ? (
<ViewListIcon fontSize="small" color="primary" />
) : (
<ComputerIcon fontSize="small" color="primary" />
)}
</ListItemIcon>
<ListItemText
primary={node.name}
primaryTypographyProps={{
variant: 'body2',
fontWeight: selectedNode === node.id ? 600 : 400
}}
/>
<Chip
label={node.count}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
bgcolor: theme => alpha(theme.palette.primary.main, 0.1),
color: 'primary.main'
}}
/>
{selectedNode === node.id && (
<CheckIcon
fontSize="small"
color="primary"
sx={{ ml: 1, fontSize: '1rem' }}
/>
)}
</MenuItem>
))}
</Select>
</FormControl>
{/* Dark Mode Toggle Button */}
<Tooltip title={darkMode ? "Switch to light mode" : "Switch to dark mode"}>
<IconButton
onClick={toggleDarkMode}
size="small"
color="inherit"
sx={{
ml: 1,
borderRadius: 2,
p: { xs: 0.8, sm: 1 },
bgcolor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.2)',
},
}}
>
{darkMode ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
<Container
maxWidth="lg"
sx={{
mt: { xs: 2, sm: 4 },
mb: { xs: 2, sm: 4 },
flexGrow: 1,
px: { xs: 2, sm: 3 },
}}
>
<Box sx={{ my: { xs: 2, sm: 4 } }}>
{/* Network Display Component - pass the selected node as a prop */}
<NetworkDisplay selectedNode={selectedNode} />
</Box>
</Container>
<Paper
sx={{
padding: 2,
marginTop: 'auto',
borderRadius: 0,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
}}
component="footer"
elevation={0}
>
<Typography variant="body2" color="text.secondary" align="center">
Pulse © {new Date().getFullYear()}
</Typography>
</Paper>
</Box>
);
}
function App() {
return (
<AppThemeProvider>
<AppContent />
</AppThemeProvider>
);
}
+83 -42
View File
@@ -43,6 +43,7 @@ import PersonIcon from '@mui/icons-material/Person';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import InputBase from '@mui/material/InputBase';
import DnsIcon from '@mui/icons-material/Dns';
// Define pulse animation
const pulseAnimation = keyframes`
@@ -242,13 +243,14 @@ const KeyboardShortcut = ({ shortcut, sx = {} }) => (
</Box>
);
const NetworkDisplay = () => {
const NetworkDisplay = ({ selectedNode = 'all' }) => {
const {
isConnected,
guestData,
metricsData,
error,
isDebugMode
isDebugMode,
nodeData
} = useSocket();
const theme = useTheme();
@@ -311,11 +313,11 @@ const NetworkDisplay = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
return saved ? JSON.parse(saved) : {
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
};
} catch (e) {
console.error('Error loading filter preferences:', e);
@@ -329,6 +331,30 @@ const NetworkDisplay = () => {
}
});
// Filter guests based on selected node
const getNodeFilteredGuests = useCallback((guests) => {
if (selectedNode === 'all') {
return guests;
}
// Filter guests based on the node property from the API
return guests.filter(guest => {
// Extract the node ID from the guest's node property
// The node property from the API is in the format "node-1", "node-2", etc.
// The selectedNode from the dropdown is in the format "node1", "node2", etc.
// We need to convert between these formats
const nodeIdFromApi = guest.node;
// If the node property doesn't exist, include the guest in all nodes
if (!nodeIdFromApi) return true;
// Convert "node-1" to "node1" format
const normalizedNodeId = nodeIdFromApi.replace('-', '');
return normalizedNodeId === selectedNode;
});
}, [selectedNode]);
const [activeSlider, setActiveSlider] = useState(null);
// Save sort preferences whenever they change
@@ -573,9 +599,12 @@ const NetworkDisplay = () => {
// Sort and filter data before displaying
const getSortedAndFilteredData = (data) => {
const sortableData = [...data];
// First filter by selected node
const nodeFilteredData = getNodeFilteredGuests(data);
// First filter the data
const sortableData = [...nodeFilteredData];
// Then filter the data
const filteredData = sortableData.filter(guest => {
// If not showing stopped and guest is not running, filter it out
if (!showStopped && guest.status !== 'running') {
@@ -713,7 +742,7 @@ const NetworkDisplay = () => {
// Memoize getSortedAndFilteredData to optimize performance
const sortedAndFilteredData = useMemo(
() => getSortedAndFilteredData(guestData),
[guestData, sortConfig, filters, showStopped, searchTerm, activeSearchTerms, displayMetrics]
[guestData, sortConfig, filters, showStopped, searchTerm, activeSearchTerms, displayMetrics, selectedNode, getNodeFilteredGuests]
);
// Add keyboard shortcut handler for 'F' key to toggle filters
@@ -796,7 +825,9 @@ const NetworkDisplay = () => {
mb: { xs: 1.5, sm: 2 },
gap: { xs: 1.5, md: 0 }
}}>
{/* Search box */}
{/* Dark Mode Toggle removed - now in App header */}
{/* Search box - moved to the left */}
<Box sx={{
display: 'flex',
alignItems: 'center',
@@ -815,7 +846,8 @@ const NetworkDisplay = () => {
'&:focus-within': {
borderColor: theme => theme.palette.primary.main,
boxShadow: theme => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`
}
},
mr: { md: 2 } // Add margin to the right on medium+ screens
}}>
<SearchIcon fontSize="small" sx={{ color: 'text.secondary', mr: 1 }} />
<InputBase
@@ -873,6 +905,38 @@ const NetworkDisplay = () => {
<KeyboardShortcut shortcut="ESC" sx={{ mr: 0.5 }} />
</Box>
</Box>
{/* Node indicator */}
{selectedNode !== 'all' && (
<Chip
icon={<DnsIcon fontSize="small" />}
label={(() => {
// Find the actual node name from nodeData
if (nodeData && nodeData.length > 0) {
// Convert selectedNode format (e.g., "node1") to API format (e.g., "node-1")
const nodeIdForApi = selectedNode.replace(/^node(\d+)$/, "node-$1");
const node = nodeData.find(n => n.id === nodeIdForApi);
if (node) {
return `Node: ${node.name}`;
}
}
// Fallback to the node ID if we can't find the name
return `Node: ${selectedNode}`;
})()}
size="small"
color="primary"
variant="outlined"
sx={{
height: 28,
mr: 2,
fontWeight: 500,
borderRadius: 1.5,
'& .MuiChip-icon': {
color: 'primary.main'
}
}}
/>
)}
<Box sx={{ flexGrow: 1, minHeight: { xs: 8, md: 0 } }} />
@@ -884,32 +948,9 @@ const NetworkDisplay = () => {
gap: { xs: 1, sm: 1.5 },
ml: { xs: 0, md: 'auto' },
width: { xs: '100%', md: 'auto' },
justifyContent: { xs: 'space-between', md: 'flex-start' }
justifyContent: { xs: 'space-between', md: 'flex-start' },
pr: { md: 5 } // Add right padding to avoid overlap with dark mode toggle
}}>
{/* Dark Mode Toggle */}
<Tooltip title={darkMode ? "Switch to light mode" : "Switch to dark mode"}>
<IconButton
onClick={toggleDarkMode}
size="small"
color={darkMode ? "primary" : "default"}
sx={{
borderRadius: 2,
p: { xs: 0.8, sm: 1 },
bgcolor: darkMode
? alpha(theme.palette.primary.main, 0.08)
: alpha(theme.palette.grey[100], 0.8),
border: '1px solid',
borderColor: darkMode
? alpha(theme.palette.primary.main, 0.2)
: 'grey.200',
transition: 'all 0.2s ease'
}}
aria-label={darkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{darkMode ? <Brightness7Icon fontSize="small" /> : <Brightness4Icon fontSize="small" />}
</IconButton>
</Tooltip>
{/* Filter controls - updated for better mobile experience */}
<Box sx={{
display: 'flex',
@@ -1404,12 +1445,12 @@ const NetworkDisplay = () => {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Typography
variant="caption"
color={Object.values(filters).some(val => val > 0) ? 'primary.main' : 'text.secondary'}
color={Object.values(filters).some(val => val > 0) || selectedNode !== 'all' ? 'primary.main' : 'text.secondary'}
sx={{ fontWeight: 500 }}
aria-live="polite" // Announce when this changes
>
{Object.values(filters).some(val => val > 0) ?
`Showing ${sortedAndFilteredData.length} of ${guestData.length} systems` :
{Object.values(filters).some(val => val > 0) || selectedNode !== 'all' ?
`Showing ${sortedAndFilteredData.length} of ${getNodeFilteredGuests(guestData).length} systems${selectedNode !== 'all' ? ` on ${selectedNode === 'node1' ? 'Production' : selectedNode === 'node2' ? 'Development' : 'Testing'}` : ''}` :
''}
</Typography>
<Chip
@@ -1417,8 +1458,8 @@ const NetworkDisplay = () => {
onClick={resetFilters}
variant={Object.values(filters).some(val => val > 0) ? "filled" : "outlined"}
size="small"
color="primary"
sx={{
color="primary"
sx={{
height: 28,
transition: 'all 0.2s ease',
fontWeight: Object.values(filters).some(val => val > 0) ? 600 : 400,