diff --git a/frontend/index.html b/frontend/index.html
index d8bf89fd0..9ae85050a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,15 +2,13 @@
-
+
-
- System Monitor
-
-
+
+ Pulse
+
+
+
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 000000000..b0d308278
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,40 @@
+
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7401806c7..9a61b496c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 (
-
-
-
-
-
+
+
+
+ {/* Enhanced Logo - Made clickable to refresh page */}
+ {
+ 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,
+ },
}}
>
+ {/* Pulse Animation Rings */}
+
- System Monitor
-
-
-
-
-
-
- {/* Network Display Component */}
-
-
-
-
-
-
- System Monitor © {new Date().getFullYear()}
+
+ Pulse
+ {
+ 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
+
+
+
-
-
+
+ {/* Node Selection Dropdown */}
+
+
+
+
+ {/* Dark Mode Toggle Button */}
+
+
+ {darkMode ? : }
+
+
+
+
+
+
+
+ {/* Network Display Component - pass the selected node as a prop */}
+
+
+
+
+
+
+ Pulse © {new Date().getFullYear()}
+
+
+
+ );
+}
+
+function App() {
+ return (
+
+
);
}
diff --git a/frontend/src/components/NetworkDisplay.jsx b/frontend/src/components/NetworkDisplay.jsx
index fe003411a..38fb0323c 100644
--- a/frontend/src/components/NetworkDisplay.jsx
+++ b/frontend/src/components/NetworkDisplay.jsx
@@ -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 = {} }) => (
);
-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 */}
{
'&: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
}}>
{
+
+ {/* Node indicator */}
+ {selectedNode !== 'all' && (
+ }
+ 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'
+ }
+ }}
+ />
+ )}
@@ -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 */}
-
-
- {darkMode ? : }
-
-
-
{/* Filter controls - updated for better mobile experience */}
{
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'}` : ''}` :
''}
{
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,