mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
v1.0.10
This commit is contained in:
+6
-8
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user