From b9f2def4ea3a8676b451c85a6ee4ef7172a2ff31 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Sat, 15 Mar 2025 19:58:32 +0000 Subject: [PATCH] Update network components and hooks to support new search and filter functionality --- .../src/components/network/NetworkDisplay.jsx | 266 +++++++++---- .../src/components/network/NetworkFilters.jsx | 137 +++++-- .../components/network/NetworkTableBody.jsx | 236 +++++++----- .../src/components/network/UIComponents.jsx | 57 ++- .../network/components/NetworkHeader.jsx | 119 +----- .../network/components/NetworkPopovers.jsx | 348 ++++-------------- .../network/hooks/useActiveFilteredColumns.js | 336 ++++++++++++++--- .../network/hooks/useDataProcessing.js | 24 +- .../network/hooks/useKeyboardShortcuts.js | 83 +++-- .../network/hooks/useNetworkFilters.js | 93 +---- .../network/hooks/usePopoverManagement.js | 60 --- .../network/hooks/useSortManagement.js | 39 +- frontend/src/hooks/useSocket.js | 1 - frontend/src/utils/networkUtils.js | 189 ++++++++-- 14 files changed, 1135 insertions(+), 853 deletions(-) diff --git a/frontend/src/components/network/NetworkDisplay.jsx b/frontend/src/components/network/NetworkDisplay.jsx index bff61acc1..3be6dac60 100644 --- a/frontend/src/components/network/NetworkDisplay.jsx +++ b/frontend/src/components/network/NetworkDisplay.jsx @@ -1,9 +1,11 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useLayoutEffect } from 'react'; import useSocket from '../../hooks/useSocket'; import useFormattedMetrics from '../../hooks/useFormattedMetrics'; import useMockMetrics from '../../hooks/useMockMetrics'; import { useThemeContext } from '../../context/ThemeContext'; -import { Box, CircularProgress, useTheme, Typography, Button } from '@mui/material'; +import { Box, CircularProgress, useTheme, Typography, Button, IconButton, Badge, Tooltip } from '@mui/material'; +import ViewColumnIcon from '@mui/icons-material/ViewColumn'; +import { createPortal } from 'react-dom'; // Import hooks import { @@ -26,6 +28,8 @@ import { NetworkTable } from './components'; +import { useSearchContext } from '../../context/SearchContext'; + const NetworkDisplay = ({ selectedNode = 'all' }) => { const { isConnected, @@ -401,25 +405,32 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { requestSort } = useSortManagement(); - // Use network filters hook + // Get search state from context instead of local state + const { + searchTerm, + setSearchTerm, + activeSearchTerms, + addSearchTerm, + removeSearchTerm, + clearSearchTerms + } = useSearchContext(); + + // Use the network filters hook but don't use its search state const { filters, + setFilters, showStopped, setShowStopped, showFilters, - searchTerm, - setSearchTerm, - activeSearchTerms, + setShowFilters, guestTypeFilter, setGuestTypeFilter, - addSearchTerm, - removeSearchTerm, + sliderDragging, updateFilter, handleSliderDragStart, handleSliderDragEnd, clearFilter, resetFilters, - clearSearchTerms, activeFilterCount } = useNetworkFilters(); @@ -427,62 +438,200 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { const { filterAnchorEl, openFilters, - searchAnchorEl, - openSearch, - searchInputRef, filterButtonRef, - searchButtonRef, handleFilterButtonClick, handleCloseFilterPopover, - handleSearchButtonClick, - handleCloseSearchPopover, closeAllPopovers } = usePopoverManagement(); - // Use keyboard shortcuts hook + // Update resetFilters to also clear search terms from context + const handleResetFilters = () => { + resetFilters(); + clearSearchTerms(); + }; + + // Use keyboard shortcuts hook with handleResetFilters instead of resetFilters useKeyboardShortcuts({ openFilters, - openSearch, openColumnMenu, - resetFilters, + resetFilters: handleResetFilters, closeAllPopovers, - handleSearchButtonClick, - searchButtonRef, - setSearchTerm, - showNotification, - searchInputRef + showNotification }); // Use data processing hook const { - extractNumericId, + processedData, getNodeName, - sortedAndFilteredData, + extractNumericId, + getNodeFilteredGuests, formatPercentage, formatNetworkRateForFilter } = useDataProcessing({ guestData, - combinedMetrics, + nodeData, sortConfig, filters, showStopped, - activeSearchTerms, - searchTerm, selectedNode, guestTypeFilter, - nodeData + metricsData: combinedMetrics }); + // Add debug logging for sorting + useEffect(() => { + console.log('Current sort configuration:', sortConfig); + console.log('Processed data after sorting:', processedData?.length || 0, 'items'); + }, [sortConfig, processedData]); + // Use active filtered columns hook const activeFilteredColumns = useActiveFilteredColumns({ filters, - activeSearchTerms, - searchTerm, guestTypeFilter, showStopped, nodeData }); + // Add an effect to listen for search term events + useEffect(() => { + const handleSearchTermAction = (event) => { + const { term, action } = event.detail; + + // Handle status: filters + if (term.startsWith('status:')) { + const status = term.split(':', 2)[1]?.trim(); + if (action === 'add') { + if (status === 'running') { + setShowStopped(false); + } else if (status === 'stopped') { + setShowStopped(true); + } + } else if (action === 'remove') { + // Reset status filter + setShowStopped(null); + } + } + + // Handle type: filters + if (term.startsWith('type:')) { + const type = term.split(':', 2)[1]?.trim(); + if (action === 'add') { + if (type === 'vm' || type === 'qemu') { + setGuestTypeFilter('vm'); + } else if (type === 'ct' || type === 'lxc' || type === 'container') { + setGuestTypeFilter('ct'); + } + } else if (action === 'remove') { + // Reset type filter to "all" + setGuestTypeFilter('all'); + } + } + + // Handle node: filters + if (term.startsWith('node:')) { + const node = term.split(':', 2)[1]?.trim(); + if (action === 'add') { + // Find the node in availableNodes + const foundNode = availableNodes.find(n => + n.name.toLowerCase() === node || + n.id.toLowerCase() === node + ); + + if (foundNode) { + // Dispatch a custom event to notify App.jsx about the node change + const nodeChangeEvent = new CustomEvent('nodeChange', { + detail: { node: foundNode.id } + }); + window.dispatchEvent(nodeChangeEvent); + } + } else if (action === 'remove') { + // Reset node filter to "all" + const nodeChangeEvent = new CustomEvent('nodeChange', { + detail: { node: 'all' } + }); + window.dispatchEvent(nodeChangeEvent); + } + } + }; + + window.addEventListener('searchTermAction', handleSearchTermAction); + + return () => { + window.removeEventListener('searchTermAction', handleSearchTermAction); + }; + }, [setShowStopped, setGuestTypeFilter, availableNodes]); + + // State for portal root element + const [portalRoot, setPortalRoot] = React.useState(null); + + // Use createPortal to place the column visibility button in the app header + useLayoutEffect(() => { + // Find the column visibility container in the app header + const columnVisibilityContainer = document.getElementById('column-visibility-app-header'); + + if (columnVisibilityContainer) { + // Create a div for the portal if it doesn't exist yet + let portalContainer = document.getElementById('column-visibility-portal'); + if (!portalContainer) { + portalContainer = document.createElement('div'); + portalContainer.id = 'column-visibility-portal'; + // Clear previous content and append the portal container + columnVisibilityContainer.innerHTML = ''; + columnVisibilityContainer.appendChild(portalContainer); + } + + // Set the portal root for rendering + setPortalRoot(portalContainer); + + // Clean up function + return () => { + setPortalRoot(null); + if (portalContainer && portalContainer.parentNode) { + portalContainer.parentNode.removeChild(portalContainer); + } + }; + } + }, []); + + // Render the column visibility button in the portal + const renderColumnVisibilityPortal = () => { + if (!portalRoot) return null; + + // Count hidden columns for the badge + const hiddenColumnsCount = Object.values(columnVisibility).filter(config => !config.visible).length; + + return createPortal( + 0 ? `Show hidden columns (${hiddenColumnsCount})` : "Column visibility settings"}> + 0 ? 'rgba(255, 255, 255, 0.1)' : 'transparent', + color: 'white', // Make sure the icon is visible in the dark app bar + '&:hover': { + bgcolor: 'rgba(255, 255, 255, 0.2)' + } + }} + > + + + + + , + portalRoot + ); + }; + // Show loading state if not connected if (!isConnected) { if (error || connectionStatus === 'disconnected' || connectionStatus === 'error') { @@ -524,35 +673,24 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { return ( - {/* Header with buttons */} - + {/* Render the column visibility button portal */} + {renderColumnVisibilityPortal()} + + {/* Column Menu - Ensure it's still rendered to handle the actual menu */} + + {openColumnMenu && ( + { + if (node && !columnMenuAnchorEl) { + handleColumnMenuOpen({ currentTarget: node }); + } + }} + /> + )} + {/* Popovers */} { updateFilter={updateFilter} handleSliderDragStart={handleSliderDragStart} handleSliderDragEnd={handleSliderDragEnd} - resetFilters={resetFilters} + resetFilters={handleResetFilters} // Formatters formatPercentage={formatPercentage} @@ -583,7 +721,7 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { columnOrder={columnOrder} setColumnOrder={setColumnOrder} activeFilteredColumns={activeFilteredColumns} - sortedAndFilteredData={sortedAndFilteredData} + sortedAndFilteredData={processedData} guestData={guestData} metricsData={combinedMetrics} getNodeName={getNodeName} @@ -598,9 +736,13 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { handleNodeChange={handleNodeChange} handleStatusChange={handleStatusChange} handleTypeChange={handleTypeChange} - activeSearchTerms={activeSearchTerms} - addSearchTerm={addSearchTerm} - removeSearchTerm={removeSearchTerm} + // Pass filter-related props + filters={filters} + updateFilter={updateFilter} + handleFilterButtonClick={handleFilterButtonClick} + filterButtonRef={filterButtonRef} + openFilters={openFilters} + handleCloseFilterPopover={handleCloseFilterPopover} /> {/* Notification Snackbar */} diff --git a/frontend/src/components/network/NetworkFilters.jsx b/frontend/src/components/network/NetworkFilters.jsx index c433134bc..0389dd982 100644 --- a/frontend/src/components/network/NetworkFilters.jsx +++ b/frontend/src/components/network/NetworkFilters.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Typography, @@ -20,7 +20,8 @@ import { ToggleButtonGroup, ToggleButton, Divider, - Button + Button, + useTheme } from '@mui/material'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; @@ -33,7 +34,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { formatPercentage, formatNetworkRateForFilter, sliderValueToNetworkRate } from '../../utils/formatters'; import { KeyboardShortcut } from './UIComponents'; -import { useTheme } from '@mui/material/styles'; +import { useSearchContext } from '../../context/SearchContext'; const NetworkFilters = ({ filters, @@ -54,9 +55,20 @@ const NetworkFilters = ({ searchInputRef, guestTypeFilter, setGuestTypeFilter, - handleClose + handleClose, + formatPercentage, + formatNetworkRateForFilter }) => { const theme = useTheme(); + // Get clearSearchTerms from context + const { clearSearchTerms } = useSearchContext(); + + // Handle resetting all filters including search terms + const handleResetAllFilters = () => { + resetFilters(); + clearSearchTerms(); + if (handleClose) handleClose(); + }; // Handle search input submission const handleSearchSubmit = (e) => { @@ -116,7 +128,14 @@ const NetworkFilters = ({ setSearchTerm(e.target.value)} + onChange={(e) => { + // Log the current input value for debugging + console.log('NetworkFilters input onChange:', e.target.value, 'Previous value:', searchTerm); + + // Directly set the search term to the current input value + // This should ensure the value is properly updated + setSearchTerm(e.target.value); + }} sx={{ flex: 1 }} inputRef={searchInputRef} inputProps={{ 'aria-label': 'search systems' }} @@ -125,7 +144,10 @@ const NetworkFilters = ({ setSearchTerm('')} + onClick={() => { + setSearchTerm(''); + searchInputRef.current?.focus(); + }} sx={{ p: 0.5 }} > @@ -237,16 +259,22 @@ const NetworkFilters = ({ {/* CPU Filter */} - + 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent' + }}> - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}> CPU Usage - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}> {formatPercentage(filters.cpu)}+ updateFilter('cpu', newValue)} onMouseDown={() => handleSliderDragStart('cpu')} @@ -257,20 +285,32 @@ const NetworkFilters = ({ min={0} max={100} size="small" + sx={{ + color: filters.cpu > 0 ? theme.palette.primary.main : undefined, + '& .MuiSlider-thumb': { + boxShadow: filters.cpu > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined, + } + }} /> {/* Memory Filter */} - + 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent' + }}> - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}> Memory Usage - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}> {formatPercentage(filters.memory)}+ updateFilter('memory', newValue)} onMouseDown={() => handleSliderDragStart('memory')} @@ -281,20 +321,32 @@ const NetworkFilters = ({ min={0} max={100} size="small" + sx={{ + color: filters.memory > 0 ? theme.palette.primary.main : undefined, + '& .MuiSlider-thumb': { + boxShadow: filters.memory > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined, + } + }} /> {/* Disk Filter */} - + 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent' + }}> - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}> Disk Usage - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}> {formatPercentage(filters.disk)}+ updateFilter('disk', newValue)} onMouseDown={() => handleSliderDragStart('disk')} @@ -305,20 +357,32 @@ const NetworkFilters = ({ min={0} max={100} size="small" + sx={{ + color: filters.disk > 0 ? theme.palette.primary.main : undefined, + '& .MuiSlider-thumb': { + boxShadow: filters.disk > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined, + } + }} /> - {/* Network Download Filter */} - + {/* Download Filter */} + 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent' + }}> - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}> Download Rate - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}> {formatNetworkRateForFilter(filters.download)}+ updateFilter('download', newValue)} onMouseDown={() => handleSliderDragStart('download')} @@ -327,22 +391,34 @@ const NetworkFilters = ({ valueLabelDisplay="auto" valueLabelFormat={formatNetworkRateForFilter} min={0} - max={100} + max={100000} size="small" + sx={{ + color: filters.download > 0 ? theme.palette.primary.main : undefined, + '& .MuiSlider-thumb': { + boxShadow: filters.download > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined, + } + }} /> - {/* Network Upload Filter */} - + {/* Upload Filter */} + 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent' + }}> - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}> Upload Rate - + 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}> {formatNetworkRateForFilter(filters.upload)}+ updateFilter('upload', newValue)} onMouseDown={() => handleSliderDragStart('upload')} @@ -351,8 +427,14 @@ const NetworkFilters = ({ valueLabelDisplay="auto" valueLabelFormat={formatNetworkRateForFilter} min={0} - max={100} + max={100000} size="small" + sx={{ + color: filters.upload > 0 ? theme.palette.primary.main : undefined, + '& .MuiSlider-thumb': { + boxShadow: filters.upload > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined, + } + }} /> @@ -365,10 +447,7 @@ const NetworkFilters = ({ size="small" fullWidth variant="outlined" - onClick={() => { - resetFilters(); - handleClose && handleClose(); - }} + onClick={handleResetAllFilters} startIcon={} > Reset All Filters diff --git a/frontend/src/components/network/NetworkTableBody.jsx b/frontend/src/components/network/NetworkTableBody.jsx index 0fff3c323..c82bc39d3 100644 --- a/frontend/src/components/network/NetworkTableBody.jsx +++ b/frontend/src/components/network/NetworkTableBody.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { TableBody, TableRow, @@ -6,12 +6,16 @@ import { Box, Typography, Chip, - Button + Button, + Divider, + alpha, + useTheme } from '@mui/material'; import NetworkCheckIcon from '@mui/icons-material/NetworkCheck'; -import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import FilterAltIcon from '@mui/icons-material/FilterList'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import NetworkTableRow from './NetworkTableRow'; +import { getNodeTextColor } from '../../utils/colorUtils'; const NetworkTableBody = ({ sortedAndFilteredData, @@ -27,8 +31,12 @@ const NetworkTableBody = ({ resetColumnVisibility, forceUpdateCounter, columnOrder, - activeFilteredColumns = {} + activeFilteredColumns = {}, + thresholdColumn, + thresholdValue }) => { + const theme = useTheme(); + // Check if any columns are visible const hasVisibleColumns = Object.values(columnVisibility).some(col => col.visible); @@ -39,6 +47,33 @@ const NetworkTableBody = ({ } }, [forceUpdateCounter]); + // Group guests by node + const groupedGuests = useMemo(() => { + if (!sortedAndFilteredData || !Array.isArray(sortedAndFilteredData)) { + return []; + } + + // Use a Map to preserve insertion order + const nodeGroups = new Map(); + + // Group guests by node + sortedAndFilteredData.forEach(guest => { + const nodeName = getNodeName(guest?.node) || 'Unknown'; + + if (!nodeGroups.has(nodeName)) { + nodeGroups.set(nodeName, []); + } + + nodeGroups.get(nodeName).push(guest); + }); + + // Convert Map to array of objects + return Array.from(nodeGroups.entries()).map(([nodeName, guests]) => ({ + nodeName, + guests + })); + }, [sortedAndFilteredData, getNodeName]); + // If no columns are visible, show a message if (!hasVisibleColumns) { return ( @@ -46,19 +81,47 @@ const NetworkTableBody = ({ - - - No Columns Visible + + + No columns are visible - - All columns are currently hidden. Click the button below to reset column visibility. + + Use the column visibility button to show some columns + + + + + ); + } + + // If no data matches the filters, show a message + if (sortedAndFilteredData.length === 0) { + return ( + + + + + + + No matching guests found + + + Try adjusting your filters or search terms + + @@ -67,82 +130,85 @@ const NetworkTableBody = ({ ); } - // If no data is available, show a message - if (!sortedAndFilteredData || sortedAndFilteredData.length === 0) { - return ( - - - - - - - No Systems Found - - - {!showStopped ? ( - <> - No running systems match your current filters. - {guestData && guestData.length > 0 && ( - <> -
- Try showing stopped systems or adjusting your filters. - - )} - - ) : ( - <> - No stopped systems match your current filters. - {guestData && guestData.length > 0 && ( - <> -
- Try showing running systems or adjusting your filters. - - )} - - )} -
- - {guestData && ( - - )} - - -
-
-
-
- ); - } - + // Calculate visible columns for node header colspan + const visibleColumnCount = Object.values(columnVisibility).filter(col => col.visible).length; + + // Render the table with data grouped by node return ( - - {Array.isArray(sortedAndFilteredData) && sortedAndFilteredData.map((guest) => ( - guest && ( - 'Unknown')} - extractNumericId={extractNumericId || ((id) => id)} - columnOrder={columnOrder || []} - activeFilteredColumns={activeFilteredColumns} - /> - ) - ))} + + {groupedGuests.map((nodeGroup, groupIndex) => { + const { nodeName, guests } = nodeGroup; + + return ( + + {/* Node Header/Divider */} + alpha(theme.palette.primary.main, 0.05), + '&:hover': { + bgcolor: theme => alpha(theme.palette.primary.main, 0.08) + } + }} + > + 0 ? '2px solid' : 'none', + borderColor: theme => alpha(theme.palette.divider, 0.8) + }} + > + + { + const color = getNodeTextColor(nodeName, theme.palette.mode); + return color; + } + }} + > + NODE: {nodeName} + + + {guests.length} {guests.length === 1 ? 'guest' : 'guests'} + + + + + + {/* Guest Rows */} + {guests.map(guest => ( + + ))} + + ); + })} ); }; diff --git a/frontend/src/components/network/UIComponents.jsx b/frontend/src/components/network/UIComponents.jsx index 985a3cb5f..364aac7ac 100644 --- a/frontend/src/components/network/UIComponents.jsx +++ b/frontend/src/components/network/UIComponents.jsx @@ -222,4 +222,59 @@ export const KeyboardShortcut = ({ shortcut, sx = {} }) => ( > {shortcut}
-); \ No newline at end of file +); + +// Add a new HighlightedText component for search term highlighting +export const HighlightedText = ({ text, searchTerms = [], variant = "body2", sx = {}, ...props }) => { + const theme = useTheme(); + + if (!text || !searchTerms || searchTerms.length === 0) { + return {text}; + } + + // Convert to string to handle numeric values + const textStr = String(text || ''); + + // Prepare regular expression for matching - case insensitive + // Escape special regex characters in search terms + const escapedTerms = searchTerms.map(term => + String(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + + // Create regex pattern from all terms + const pattern = new RegExp(`(${escapedTerms.join('|')})`, 'gi'); + + // Split text by matches + const parts = textStr.split(pattern); + + // Skip highlighting if no matches + if (parts.length <= 1) { + return {text}; + } + + return ( + + {parts.map((part, i) => { + // Check if this part matches any search term (case insensitive) + const isMatch = searchTerms.some(term => + part.toLowerCase() === String(term).toLowerCase() + ); + + return isMatch ? ( + + {part} + + ) : part; + })} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/network/components/NetworkHeader.jsx b/frontend/src/components/network/components/NetworkHeader.jsx index 223045dc3..c65f7300b 100644 --- a/frontend/src/components/network/components/NetworkHeader.jsx +++ b/frontend/src/components/network/components/NetworkHeader.jsx @@ -1,118 +1,29 @@ import React from 'react'; import { - Box, - Typography, - IconButton, - Badge + Box } from '@mui/material'; -import ViewColumnIcon from '@mui/icons-material/ViewColumn'; -import SearchIcon from '@mui/icons-material/Search'; -import TuneIcon from '@mui/icons-material/Tune'; -import { DEFAULT_COLUMN_CONFIG } from '../../../constants/networkConstants'; const NetworkHeader = ({ - openColumnMenu, - openSearch, openFilters, - activeSearchTerms, filters, - columnVisibility, - handleColumnMenuOpen, - handleSearchButtonClick, handleFilterButtonClick, - searchButtonRef, filterButtonRef }) => { return ( - - - {/* Column visibility button */} - - col.visible).length} - color="primary" - invisible={Object.values(columnVisibility).filter(col => col.visible).length === Object.keys(DEFAULT_COLUMN_CONFIG).length} - > - - - - - {/* Search button */} - - - - - - - {/* Resource thresholds button */} - - 0 ? 1 : 0) + - (filters.memory > 0 ? 1 : 0) + - (filters.disk > 0 ? 1 : 0) + - (filters.download > 0 ? 1 : 0) + - (filters.upload > 0 ? 1 : 0) - } - color="primary" - invisible={ - filters.cpu === 0 && - filters.memory === 0 && - filters.disk === 0 && - filters.download === 0 && - filters.upload === 0 - } - > - - - + + {/* Left side - Intentionally empty, removed Dashboard title */} + + + {/* Right side - Actions - Column visibility button has been moved to the main app header */} + + {/* Additional buttons can be added here if needed */} ); diff --git a/frontend/src/components/network/components/NetworkPopovers.jsx b/frontend/src/components/network/components/NetworkPopovers.jsx index dd20fba92..7a0b6f939 100644 --- a/frontend/src/components/network/components/NetworkPopovers.jsx +++ b/frontend/src/components/network/components/NetworkPopovers.jsx @@ -4,35 +4,14 @@ import { Paper, Box, Typography, - TextField, IconButton, - Chip, Button, - ToggleButtonGroup, - ToggleButton, - MenuItem, - Switch, - Slider, - Divider, - Radio + Slider } from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; -import ClearIcon from '@mui/icons-material/Clear'; import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; +import { useSearchContext } from '../../../context/SearchContext'; const NetworkPopovers = ({ - // Search popover props - searchAnchorEl, - openSearch, - handleCloseSearchPopover, - searchTerm, - setSearchTerm, - activeSearchTerms, - addSearchTerm, - removeSearchTerm, - searchInputRef, - clearSearchTerms, - // Filter popover props filterAnchorEl, openFilters, @@ -47,196 +26,19 @@ const NetworkPopovers = ({ formatPercentage, formatNetworkRateForFilter }) => { + // Get clearSearchTerms from context to ensure search terms are cleared when filters are reset + const { clearSearchTerms } = useSearchContext(); + + // Handle resetting all filters including search terms + const handleResetAllFilters = () => { + resetFilters(); + clearSearchTerms(); + handleCloseFilterPopover(); + }; + return ( <> - {/* Search popover */} - - - - - Search Systems - - - Search for specific systems by name, ID, or other properties. - - - - - { - e.preventDefault(); - if (searchTerm.trim()) { - // Add the entire search term (which can contain multiple space-separated words) - // This creates an OR search (spaces act as OR operators) - addSearchTerm(searchTerm.trim()); - setSearchTerm(''); - } - }} - sx={{ display: 'flex', alignItems: 'center' }} - > - - setSearchTerm(e.target.value)} - inputRef={searchInputRef} - autoFocus - variant="outlined" - InputProps={{ - endAdornment: searchTerm ? ( - setSearchTerm('')} - sx={{ p: 0.5 }} - > - - - ) : null, - inputProps: { - autoCapitalize: "none", - autoComplete: "off", - autoCorrect: "off", - spellCheck: "false" - } - }} - sx={{ flex: 1 }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (searchTerm.trim()) { - // Add the entire search term (which can contain multiple space-separated words) - // This creates an OR search (spaces act as OR operators) - addSearchTerm(searchTerm.trim()); - setSearchTerm(''); - } - } - }} - /> - - - - - Search Tips: -
Just start typing anywhere to search -
• Use status:running or status:stopped to filter by status -
• Use type:vm or type:ct for filtering by system type -
• Type multiple words for OR search (e.g., vm server) -
• Add multiple search terms for AND filtering -
- - { - // Don't just set the search term, directly add it as a filter - addSearchTerm('status:running'); - }} - sx={{ borderStyle: 'dashed' }} - /> - { - // Don't just set the search term, directly add it as a filter - addSearchTerm('status:stopped'); - }} - sx={{ borderStyle: 'dashed' }} - /> - { - // Don't just set the search term, directly add it as a filter - addSearchTerm('type:vm'); - }} - sx={{ borderStyle: 'dashed' }} - /> - { - // Don't just set the search term, directly add it as a filter - addSearchTerm('type:ct'); - }} - sx={{ borderStyle: 'dashed' }} - /> - -
- - {activeSearchTerms.length > 0 && ( - - - Active Filters: - - - {activeSearchTerms.map((term) => ( - removeSearchTerm(term)} - sx={{ - bgcolor: 'primary.main', - color: 'primary.contrastText', - '& .MuiChip-deleteIcon': { - color: 'primary.contrastText', - opacity: 0.7, - '&:hover': { - opacity: 1 - } - } - }} - /> - ))} - - - )} - - - - -
-
-
- - {/* Resource thresholds popover */} + {/* Filter popover */} - {/* CPU Filter */} - - - - CPU Usage - + {/* CPU filter */} + + + CPU Usage - {formatPercentage(filters.cpu)}+ + {formatPercentage(filters.cpu)} updateFilter('cpu', newValue)} onMouseDown={() => handleSliderDragStart('cpu')} onMouseUp={handleSliderDragEnd} - aria-labelledby="cpu-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} min={0} max={100} - size="small" + step={1} + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + aria-labelledby="cpu-usage-slider" /> - {/* Memory Filter */} - - - - Memory Usage - + {/* Memory filter */} + + + Memory Usage - {formatPercentage(filters.memory)}+ + {formatPercentage(filters.memory)} updateFilter('memory', newValue)} onMouseDown={() => handleSliderDragStart('memory')} onMouseUp={handleSliderDragEnd} - aria-labelledby="memory-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} min={0} max={100} - size="small" + step={1} + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + aria-labelledby="memory-usage-slider" /> - {/* Disk Filter */} - - - - Disk Usage - + {/* Disk filter */} + + + Disk Usage - {formatPercentage(filters.disk)}+ + {formatPercentage(filters.disk)} updateFilter('disk', newValue)} onMouseDown={() => handleSliderDragStart('disk')} onMouseUp={handleSliderDragEnd} - aria-labelledby="disk-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} min={0} max={100} - size="small" + step={1} + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + aria-labelledby="disk-usage-slider" /> - {/* Network Download Filter */} - - - - Download Rate - + {/* Download filter */} + + + Download Rate - {formatNetworkRateForFilter(filters.download)}+ + {formatNetworkRateForFilter(filters.download)} updateFilter('download', newValue)} onMouseDown={() => handleSliderDragStart('download')} onMouseUp={handleSliderDragEnd} - aria-labelledby="download-filter-slider" + min={0} + max={100000} + step={1} valueLabelDisplay="auto" valueLabelFormat={formatNetworkRateForFilter} - min={0} - max={100} - size="small" + aria-labelledby="download-rate-slider" /> - {/* Network Upload Filter */} - - - - Upload Rate - + {/* Upload filter */} + + + Upload Rate - {formatNetworkRateForFilter(filters.upload)}+ + {formatNetworkRateForFilter(filters.upload)} updateFilter('upload', newValue)} onMouseDown={() => handleSliderDragStart('upload')} onMouseUp={handleSliderDragEnd} - aria-labelledby="upload-filter-slider" + min={0} + max={100000} + step={1} valueLabelDisplay="auto" valueLabelFormat={formatNetworkRateForFilter} - min={0} - max={100} - size="small" + aria-labelledby="upload-rate-slider" /> - - - - diff --git a/frontend/src/components/network/hooks/useActiveFilteredColumns.js b/frontend/src/components/network/hooks/useActiveFilteredColumns.js index 6a4a9a17d..b59213ac2 100644 --- a/frontend/src/components/network/hooks/useActiveFilteredColumns.js +++ b/frontend/src/components/network/hooks/useActiveFilteredColumns.js @@ -1,13 +1,15 @@ import { useMemo } from 'react'; +import { useSearchContext } from '../../../context/SearchContext'; const useActiveFilteredColumns = ({ filters, - activeSearchTerms, - searchTerm, guestTypeFilter, showStopped, nodeData }) => { + // Get search state from context + const { searchTerm, activeSearchTerms } = useSearchContext(); + // Determine which columns have active filters const activeFilteredColumns = useMemo(() => { const result = {}; @@ -20,70 +22,34 @@ const useActiveFilteredColumns = ({ if (filters.upload > 0) result.netOut = true; // Search terms - determine which column to highlight based on the search term + // This is now only used for UI indicators in header (like filter icons) + // not for highlighting anymore if (activeSearchTerms.length > 0 || searchTerm) { const allTerms = [...activeSearchTerms]; if (searchTerm) allTerms.push(searchTerm); - // Check each term and determine which column(s) to highlight + // Process node data for efficient matching + const nodeInfo = processNodeData(nodeData); + + // Process each search term to determine which column(s) to highlight allTerms.forEach(term => { const termLower = term.trim().toLowerCase(); - // Handle column-specific searches (using prefixes) - if (termLower.includes(':')) { - const [prefix, value] = termLower.split(':', 2); - - switch (prefix.trim()) { - case 'name': - result.name = true; - return; // Skip other checks for this term - case 'id': - result.id = true; - return; // Skip other checks for this term - case 'node': - result.node = true; - return; // Skip other checks for this term - case 'status': - result.status = true; - return; // Skip other checks for this term - case 'type': - result.type = true; - return; // Skip other checks for this term - } - } + // Skip empty terms + if (!termLower) return; - // Check for exact type matches - if (termLower === 'ct' || termLower === 'container') { - result.type = true; - return; // Skip other checks for this term - } + // Try to match the term to specific column types + // This now returns an array of column types for partial matches + const columnTypes = identifyColumnTypes(termLower, nodeInfo); - if (termLower === 'vm' || termLower === 'virtual machine') { - result.type = true; - return; // Skip other checks for this term - } - - // Check if term is a numeric ID - if (/^\d+$/.test(termLower)) { - result.id = true; - } - // Check if term matches type-related keywords - else if (['qemu', 'lxc'].includes(termLower)) { - result.type = true; - } - // Check if term matches status-related keywords - else if (['running', 'stopped', 'online', 'offline', 'active', 'inactive'].includes(termLower)) { - result.status = true; - } - // Check if term might be a node name - else if (nodeData && nodeData.some(node => - (node.name && node.name.toLowerCase().includes(termLower)) || - (node.id && node.id.toLowerCase().includes(termLower)) - )) { - result.node = true; - } - // Default to name column for other terms - else { - result.name = true; + // Apply the appropriate highlighting for all matching column types + if (Array.isArray(columnTypes)) { + columnTypes.forEach(columnType => { + applyColumnHighlighting(result, columnType, termLower); + }); + } else if (columnTypes) { + // For backward compatibility - still handle single column type + applyColumnHighlighting(result, columnTypes, termLower); } }); } @@ -104,4 +70,260 @@ const useActiveFilteredColumns = ({ return activeFilteredColumns; }; +// Helper function to process node data for efficient matching +function processNodeData(nodeData) { + const nodeNames = []; + const nodeIds = []; + const nodePatterns = []; + + if (nodeData && Array.isArray(nodeData)) { + nodeData.forEach(node => { + if (node.name) nodeNames.push(node.name.toLowerCase()); + if (node.id) nodeIds.push(node.id.toLowerCase()); + + // Create common node name patterns + const name = (node.name || '').toLowerCase(); + if (name.includes('-')) { + // Add base name (e.g., 'pve' from 'pve-01') + const baseName = name.split('-')[0]; + if (baseName && !nodePatterns.includes(baseName)) { + nodePatterns.push(baseName); + } + + // Add prefix with first digit (e.g., 'pve-0' from 'pve-01') + const parts = name.split('-'); + if (parts.length > 1 && parts[1].length > 0) { + const prefix = parts[0] + '-' + parts[1][0]; + if (!nodePatterns.includes(prefix)) { + nodePatterns.push(prefix); + } + } + } + }); + } + + return { nodeNames, nodeIds, nodePatterns }; +} + +// Helper function to identify which columns a term should highlight +// Returns an array of column types for partial matches, or a single string for exact matches +function identifyColumnTypes(term, nodeInfo) { + // The logic is still used for filter icons in the header + // For exact resource keywords or expressions + + // Resource keywords (exact matches) + if (['cpu', 'memory', 'mem', 'disk', 'network', 'net'].includes(term)) { + if (term === 'network' || term === 'net') { + return 'network'; + } + if (term === 'mem') { + return 'memory'; + } + return term; + } + + // Partial resource expressions (e.g., "cpu>") + if (/^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)$/i.test(term)) { + const resource = term.match(/^(cpu|mem(ory)?|disk|network|net)/i)[0].toLowerCase(); + if (resource === 'network' || resource === 'net') { + return 'network'; + } + if (resource === 'mem') { + return 'memory'; + } + return resource; + } + + // Complete resource expressions (e.g., "cpu>50") + const resourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i; + const resourceMatch = term.match(resourceExpressionRegex); + if (resourceMatch) { + let resource = resourceMatch[1].toLowerCase(); + if (resource === 'network' || resource === 'net') { + return 'network'; + } + if (resource === 'mem') { + return 'memory'; + } + return resource; + } + + // Column-specific searches with colon (e.g., "name:ubuntu") + if (term.includes(':')) { + const [prefix] = term.split(':', 2); + const validPrefixes = ['name', 'id', 'node', 'status', 'type', 'cpu', 'memory', 'mem', 'disk', 'network', 'net']; + + if (validPrefixes.includes(prefix.trim())) { + const prefixTrim = prefix.trim(); + if (prefixTrim === 'network' || prefixTrim === 'net') { + return 'network'; + } + if (prefixTrim === 'mem') { + return 'memory'; + } + return prefixTrim; + } + } + + // For short partial terms (1-2 characters), we'll highlight multiple potential matches + if (term.length <= 2) { + const matchingColumns = []; + + // Check for partial matches in all possible terms + + // Resource terms + const resourceTerms = ['cpu', 'memory', 'disk', 'network', 'net']; + for (const resource of resourceTerms) { + if (resource.startsWith(term)) { + matchingColumns.push(resource === 'network' || resource === 'net' ? 'network' : resource); + } + } + + // Type terms + const typeTerms = ['ct', 'container', 'vm', 'virtual', 'machine', 'qemu', 'lxc']; + if (typeTerms.some(type => type.startsWith(term))) { + matchingColumns.push('type'); + } + + // Status terms + const statusTerms = ['running', 'stopped', 'online', 'offline', 'active', 'inactive']; + if (statusTerms.some(status => status.startsWith(term))) { + matchingColumns.push('status'); + } + + // Node terms - check if term matches beginning of any node name/id + const { nodeNames, nodeIds } = nodeInfo; + if (nodeNames.some(name => name.startsWith(term)) || + nodeIds.some(id => id.startsWith(term))) { + matchingColumns.push('node'); + } + + // For single letters, also include ID and name as potential matches + if (term.length === 1) { + // Always include name for single letter (could be start of any name) + if (!matchingColumns.includes('name')) matchingColumns.push('name'); + + // For single digit, include ID + if (/^\d$/.test(term) && !matchingColumns.includes('id')) { + matchingColumns.push('id'); + } + } + + // If we found any matches, return them + if (matchingColumns.length > 0) { + return matchingColumns; + } + + // For 1-2 character alpha terms with no specific matches, + // default to highlighting name column as it's the most likely target + if (/[a-z]/i.test(term)) { + return ['name']; + } + } + + // Past this point, handle longer terms (3+ chars) with more specific highlighting + + // Type-specific terms (exact matches) + if (['ct', 'container', 'vm', 'virtual machine', 'qemu', 'lxc'].includes(term)) { + return 'type'; + } + + // Status-specific terms (exact matches) + const statusTerms = ['running', 'stopped', 'online', 'offline', 'active', 'inactive']; + if (statusTerms.includes(term)) { + return 'status'; + } + + // Partial status term matches (e.g., "runni" should match "running") + // This is critical for providing good feedback while typing + if (statusTerms.some(status => status.startsWith(term) || term.startsWith(status))) { + return 'status'; + } + + // Partial type term matches (e.g., "conta" should match "container") + const typeTerms = ['ct', 'container', 'vm', 'virtual', 'machine', 'qemu', 'lxc']; + if (typeTerms.some(type => type.startsWith(term) || term.startsWith(type))) { + return 'type'; + } + + // ID-specific terms (pure numbers) + if (/^\d+$/.test(term)) { + return 'id'; + } + + // Node matching + const { nodeNames, nodeIds, nodePatterns } = nodeInfo; + + // Exact node name/id match + if (nodeNames.includes(term) || nodeIds.includes(term)) { + return 'node'; + } + + // Node name contains term or term contains node name + if (nodeNames.some(name => name.includes(term) || term.includes(name))) { + return 'node'; + } + + // Node id contains term or term contains node id + if (nodeIds.some(id => id.includes(term) || term.includes(id))) { + return 'node'; + } + + // Term matches node patterns + if (nodePatterns.some(pattern => term.includes(pattern))) { + return 'node'; + } + + // Term is likely a node reference + if (term.includes('-') || /^[a-z]{1,3}\d{1,2}$/i.test(term)) { + return 'node'; + } + + // Default - this is likely a name search + // Only highlight name if it's likely to be a name search and + // doesn't match any partial patterns above + if (/[a-z]/i.test(term) && term.length > 1) { + return 'name'; + } + + // If we can't determine a specific column, don't highlight anything + return null; +} + +// Helper function to apply highlighting based on column type +function applyColumnHighlighting(result, columnType, term) { + if (!columnType) return; + + switch (columnType) { + case 'cpu': + result.cpu = true; + break; + case 'memory': + result.memory = true; + break; + case 'disk': + result.disk = true; + break; + case 'network': + result.netIn = true; + result.netOut = true; + break; + case 'node': + result.node = true; + break; + case 'id': + result.id = true; + break; + case 'status': + result.status = true; + break; + case 'type': + result.type = true; + break; + case 'name': + result.name = true; + break; + } +} + export default useActiveFilteredColumns; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useDataProcessing.js b/frontend/src/components/network/hooks/useDataProcessing.js index eba60a419..c43536a66 100644 --- a/frontend/src/components/network/hooks/useDataProcessing.js +++ b/frontend/src/components/network/hooks/useDataProcessing.js @@ -1,18 +1,20 @@ import { useMemo, useCallback } from 'react'; import { getSortedAndFilteredData, getNodeFilteredGuests as nodeFilteredGuestsUtil, getNodeName as getNodeNameUtil, extractNumericId as extractNumericIdUtil } from '../../../utils/networkUtils'; +import { useSearchContext } from '../../../context/SearchContext'; const useDataProcessing = ({ guestData, - combinedMetrics, + nodeData, sortConfig, filters, showStopped, - activeSearchTerms, - searchTerm, selectedNode, guestTypeFilter, - nodeData + metricsData }) => { + // Get search state from context + const { searchTerm, activeSearchTerms } = useSearchContext(); + // Helper function to extract numeric ID from strings like "node-1-ct-105" const extractNumericId = useCallback((fullId) => { return extractNumericIdUtil(fullId); @@ -29,17 +31,17 @@ const useDataProcessing = ({ }, [selectedNode]); // Get sorted and filtered data - const sortedAndFilteredData = useMemo(() => { + const processedData = useMemo(() => { // Debug logging console.log('useDataProcessing - Processing data:'); console.log('- guestData:', guestData?.length || 0, 'guests'); console.log('- selectedNode:', selectedNode); - console.log('- combinedMetrics:', combinedMetrics ? 'available' : 'not available'); + console.log('- sortConfig:', sortConfig); // First filter by node const nodeFilteredData = selectedNode === 'all' ? guestData - : getNodeFilteredGuests(guestData, selectedNode); + : getNodeFilteredGuests(guestData); console.log('- nodeFilteredData:', nodeFilteredData?.length || 0, 'guests after node filtering'); @@ -51,7 +53,7 @@ const useDataProcessing = ({ showStopped, activeSearchTerms, searchTerm, - combinedMetrics, + metricsData, guestTypeFilter, nodeData ); @@ -60,7 +62,6 @@ const useDataProcessing = ({ return result; }, [ guestData, - combinedMetrics, sortConfig, filters, showStopped, @@ -69,7 +70,8 @@ const useDataProcessing = ({ selectedNode, getNodeFilteredGuests, guestTypeFilter, - nodeData + nodeData, + metricsData ]); // Format percentage for display @@ -90,7 +92,7 @@ const useDataProcessing = ({ extractNumericId, getNodeName, getNodeFilteredGuests, - sortedAndFilteredData, + processedData, formatPercentage, formatNetworkRateForFilter }; diff --git a/frontend/src/components/network/hooks/useKeyboardShortcuts.js b/frontend/src/components/network/hooks/useKeyboardShortcuts.js index de77615d8..8f04ba74f 100644 --- a/frontend/src/components/network/hooks/useKeyboardShortcuts.js +++ b/frontend/src/components/network/hooks/useKeyboardShortcuts.js @@ -1,28 +1,43 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSearchContext } from '../../../context/SearchContext'; const useKeyboardShortcuts = ({ openFilters, - openSearch, openColumnMenu, resetFilters, closeAllPopovers, - handleSearchButtonClick, - searchButtonRef, - setSearchTerm, - showNotification, - searchInputRef + showNotification }) => { const [escRecentlyPressed, setEscRecentlyPressed] = useState(false); - + + // Get search functions from context directly + const { setSearchTerm, setIsSearching, clearSearchTerms, isSearching } = useSearchContext(); + + // Track last key pressed timestamp to prevent double handling of events + const lastKeyPressTime = useRef(0); + // Set up keyboard shortcuts useEffect(() => { + // Global keyboard handler const handleGlobalKeyDown = (e) => { - // Don't trigger shortcuts if typing in an input field - if ( + // Critical protection against double event handling + // This prevents the same key event from being processed twice + const now = Date.now(); + if (now - lastKeyPressTime.current < 50) { + return; // Ignore events that are too close together + } + lastKeyPressTime.current = now; + + // STOP HANDLING KEYBOARD SHORTCUTS IF USER IS TYPING IN A FORM ELEMENT + // This is the most important check to prevent shortcuts from interfering with typing + const isEditableElement = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || - e.target.isContentEditable - ) { + e.target.isContentEditable || + e.target.getAttribute('role') === 'textbox'; + + // If user is typing in ANY input, don't hijack their keystrokes + if (isEditableElement) { return; } @@ -34,6 +49,9 @@ const useKeyboardShortcuts = ({ // Reset all filters resetFilters(); + // Clear all search terms + clearSearchTerms(); + // Set flag to prevent other shortcuts from triggering setEscRecentlyPressed(true); setTimeout(() => setEscRecentlyPressed(false), 300); @@ -46,25 +64,31 @@ const useKeyboardShortcuts = ({ // Ctrl+F or Cmd+F to focus search without setting a term if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); - handleSearchButtonClick({ currentTarget: searchButtonRef.current }); + setIsSearching(true); return; } // / to focus search without setting a term if (e.key === '/' && !escRecentlyPressed) { e.preventDefault(); - handleSearchButtonClick({ currentTarget: searchButtonRef.current }); + setIsSearching(true); return; } // Enter to focus search without setting a term if (e.key === 'Enter' && !escRecentlyPressed) { e.preventDefault(); - handleSearchButtonClick({ currentTarget: searchButtonRef.current }); + setIsSearching(true); return; } - // Capture single printable characters to both open search AND start typing + // Respect the search state + if (isSearching) { + // If the search field is focused, don't handle keyboard shortcuts + return; + } + + // Capture single printable characters to focus search AND start typing const isPrintableChar = e.key.length === 1 && !e.ctrlKey && @@ -72,39 +96,34 @@ const useKeyboardShortcuts = ({ !e.altKey && !e.key.match(/^F\d+$/); // Exclude function keys - if (isPrintableChar && !openSearch) { + if (isPrintableChar) { e.preventDefault(); - // Store the first character - const firstChar = e.key; + // Set the first character + setSearchTerm(e.key); - // Open the search popover and focus the input field - handleSearchButtonClick({ currentTarget: searchButtonRef.current }); - - // Set the search term after a short delay to ensure input is focused - setTimeout(() => { - setSearchTerm(firstChar); - }, 10); + // Focus the search field + setIsSearching(true); } - - // NOTE: We no longer activate search on random typing - only explicit shortcuts }; + // Use the capture phase to get events before other handlers window.addEventListener('keydown', handleGlobalKeyDown); + return () => { window.removeEventListener('keydown', handleGlobalKeyDown); }; }, [ openFilters, - openSearch, openColumnMenu, escRecentlyPressed, resetFilters, closeAllPopovers, - handleSearchButtonClick, - searchButtonRef, setSearchTerm, - showNotification + setIsSearching, + clearSearchTerms, + showNotification, + isSearching ]); return { diff --git a/frontend/src/components/network/hooks/useNetworkFilters.js b/frontend/src/components/network/hooks/useNetworkFilters.js index ef5108bcd..4901b5a9b 100644 --- a/frontend/src/components/network/hooks/useNetworkFilters.js +++ b/frontend/src/components/network/hooks/useNetworkFilters.js @@ -3,7 +3,6 @@ import { STORAGE_KEY_FILTERS, STORAGE_KEY_SHOW_STOPPED, STORAGE_KEY_SHOW_FILTERS, - STORAGE_KEY_SEARCH_TERMS, STORAGE_KEY_GUEST_TYPE_FILTER } from '../../../constants/networkConstants'; @@ -57,18 +56,6 @@ const useNetworkFilters = () => { } }); - // Search state - const [searchTerm, setSearchTerm] = useState(''); - const [activeSearchTerms, setActiveSearchTerms] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY_SEARCH_TERMS); - return saved ? JSON.parse(saved) : []; - } catch (e) { - console.error('Error loading active search terms:', e); - return []; - } - }); - // Add guest type filter state - load from localStorage or use default const [guestTypeFilter, setGuestTypeFilter] = useState(() => { try { @@ -102,70 +89,11 @@ const useNetworkFilters = () => { localStorage.setItem(STORAGE_KEY_SHOW_FILTERS, JSON.stringify(showFilters)); }, [showFilters]); - // Save active search terms whenever they change - useEffect(() => { - localStorage.setItem(STORAGE_KEY_SEARCH_TERMS, JSON.stringify(activeSearchTerms)); - }, [activeSearchTerms]); - // Save filter preferences whenever they change useEffect(() => { localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(filters)); }, [filters]); - // Function to add a search term - const addSearchTerm = useCallback((term) => { - if (!activeSearchTerms.includes(term)) { - setActiveSearchTerms(prev => [...prev, term]); - - // Also update the corresponding column filter state - const termLower = term.toLowerCase().trim(); - - // Handle status: filters - if (termLower.startsWith('status:')) { - const status = termLower.split(':', 2)[1]?.trim(); - if (status === 'running') { - setShowStopped(false); - } else if (status === 'stopped') { - setShowStopped(true); - } - } - - // Handle type: filters - if (termLower.startsWith('type:')) { - const type = termLower.split(':', 2)[1]?.trim(); - if (type === 'vm' || type === 'qemu') { - setGuestTypeFilter('vm'); - } else if (type === 'ct' || type === 'lxc' || type === 'container') { - setGuestTypeFilter('ct'); - } - } - - // Note: node: filters are handled elsewhere as they require the availableNodes data - } - }, [activeSearchTerms, setShowStopped, setGuestTypeFilter]); - - // Function to remove a search term - const removeSearchTerm = useCallback((term) => { - setActiveSearchTerms(prev => prev.filter(t => t !== term)); - - // Also update the corresponding column filter - const termLower = term.toLowerCase().trim(); - - // Handle node: filters - we don't handle this here since selectedNode is managed elsewhere - - // Handle status: filters - if (termLower.startsWith('status:')) { - // Reset status filter - setShowStopped(null); - } - - // Handle type: filters - if (termLower.startsWith('type:')) { - // Reset type filter to "all" - setGuestTypeFilter('all'); - } - }, [setShowStopped, setGuestTypeFilter]); - // Update filter value const updateFilter = useCallback((filterName, newValue) => { setFilters(prev => ({ @@ -201,22 +129,12 @@ const useNetworkFilters = () => { download: 0, upload: 0 }); - setActiveSearchTerms([]); - setSearchTerm(''); setShowStopped(null); setGuestTypeFilter('all'); }, []); - // Function to clear only search terms - const clearSearchTerms = useCallback(() => { - setActiveSearchTerms([]); - setSearchTerm(''); - }, []); - - // Count active filters - const activeFilterCount = activeSearchTerms.length + - (searchTerm && !activeSearchTerms.includes(searchTerm) ? 1 : 0) + - Object.values(filters).filter(val => val > 0).length; + // Count active filters - now only count the slider filters + const activeFilterCount = Object.values(filters).filter(val => val > 0).length; return { filters, @@ -225,21 +143,14 @@ const useNetworkFilters = () => { setShowStopped, showFilters, setShowFilters, - searchTerm, - setSearchTerm, - activeSearchTerms, - setActiveSearchTerms, guestTypeFilter, setGuestTypeFilter, sliderDragging, - addSearchTerm, - removeSearchTerm, updateFilter, handleSliderDragStart, handleSliderDragEnd, clearFilter, resetFilters, - clearSearchTerms, activeFilterCount }; }; diff --git a/frontend/src/components/network/hooks/usePopoverManagement.js b/frontend/src/components/network/hooks/usePopoverManagement.js index f701b125e..1bdaa9b9c 100644 --- a/frontend/src/components/network/hooks/usePopoverManagement.js +++ b/frontend/src/components/network/hooks/usePopoverManagement.js @@ -5,17 +5,9 @@ const usePopoverManagement = () => { const [filterAnchorEl, setFilterAnchorEl] = useState(null); const openFilters = Boolean(filterAnchorEl); - // State for search popover - const [searchAnchorEl, setSearchAnchorEl] = useState(null); - const openSearch = Boolean(searchAnchorEl); - const searchInputRef = useRef(null); - // Add a ref for the filter button const filterButtonRef = useRef(null); - // Add a ref for the search button - const searchButtonRef = useRef(null); - // Filter popover handlers const handleFilterButtonClick = useCallback((event) => { setFilterAnchorEl(event.currentTarget); @@ -24,70 +16,18 @@ const usePopoverManagement = () => { const handleCloseFilterPopover = useCallback(() => { setFilterAnchorEl(null); }, []); - - // Search popover handlers - const handleSearchButtonClick = useCallback((event) => { - // Save the button for blur later - const button = event.currentTarget; - - // Set anchor for popover - setSearchAnchorEl(event.currentTarget); - - // Immediately remove focus from the search button to prevent the ripple effect - if (button && typeof button.blur === 'function') { - button.blur(); - } - - // Use a timeout to ensure the popover is rendered - // before trying to focus the input - setTimeout(() => { - // Try multiple focus approaches to ensure it works - if (searchInputRef.current) { - // For TextField component - if (typeof searchInputRef.current.focus === 'function') { - searchInputRef.current.focus(); - } - - // For direct DOM access - this is more reliable - const inputElement = searchInputRef.current.querySelector ? - searchInputRef.current.querySelector('input') : - searchInputRef.current; - - if (inputElement && typeof inputElement.focus === 'function') { - inputElement.focus(); - - // If there's text, place cursor at the end - if (inputElement.value && typeof inputElement.setSelectionRange === 'function') { - const length = inputElement.value.length; - inputElement.setSelectionRange(length, length); - } - } - } - }, 50); // Shorter timeout for better responsiveness - }, []); - - const handleCloseSearchPopover = useCallback(() => { - setSearchAnchorEl(null); - }, []); // Function to close all popovers const closeAllPopovers = useCallback(() => { setFilterAnchorEl(null); - setSearchAnchorEl(null); }, []); return { filterAnchorEl, openFilters, - searchAnchorEl, - openSearch, - searchInputRef, filterButtonRef, - searchButtonRef, handleFilterButtonClick, handleCloseFilterPopover, - handleSearchButtonClick, - handleCloseSearchPopover, closeAllPopovers }; }; diff --git a/frontend/src/components/network/hooks/useSortManagement.js b/frontend/src/components/network/hooks/useSortManagement.js index d4424c8a7..f24c96ff0 100644 --- a/frontend/src/components/network/hooks/useSortManagement.js +++ b/frontend/src/components/network/hooks/useSortManagement.js @@ -6,21 +6,46 @@ const useSortManagement = () => { const [sortConfig, setSortConfig] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY_SORT); - return saved ? JSON.parse(saved) : { key: 'id', direction: 'asc' }; + return saved ? JSON.parse(saved) : { key: "node", direction: 'asc' }; } catch (e) { console.error('Error loading sort preferences:', e); - return { key: 'id', direction: 'asc' }; + return { key: "node", direction: 'asc' }; } }); // Request sort by key - const requestSort = useCallback((key) => { - setSortConfig(prev => ({ - key, - direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc' - })); + const requestSort = useCallback((key, forcedDirection) => { + console.log(`Sorting by ${key}${forcedDirection ? ' with forced direction: ' + forcedDirection : ''}`); + + setSortConfig(prev => { + // If a forced direction is provided, use that + if (forcedDirection) { + const newConfig = { + key, + direction: forcedDirection + }; + console.log(`Setting sort: ${key} → ${forcedDirection} (forced)`); + return newConfig; + } + + // Otherwise toggle direction if same key, or default to ascending for new key + const newDirection = prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'; + + const newConfig = { + key, + direction: newDirection + }; + + console.log(`Setting sort: ${key} → ${newDirection} (toggled from ${prev.key === key ? prev.direction : 'new column'})`); + return newConfig; + }); }, []); + // Log sort config changes for debugging + useEffect(() => { + console.log('Current sort config:', sortConfig); + }, [sortConfig]); + // Save sort preferences whenever they change useEffect(() => { localStorage.setItem(STORAGE_KEY_SORT, JSON.stringify(sortConfig)); diff --git a/frontend/src/hooks/useSocket.js b/frontend/src/hooks/useSocket.js index 3ba0f2437..b90590aa6 100644 --- a/frontend/src/hooks/useSocket.js +++ b/frontend/src/hooks/useSocket.js @@ -1078,7 +1078,6 @@ const useSocket = (url) => { }, [isConnected]); return { - socket: socketRef.current, isConnected, lastMessage, error, diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js index fdbd53ada..6c37e577b 100644 --- a/frontend/src/utils/networkUtils.js +++ b/frontend/src/utils/networkUtils.js @@ -159,7 +159,7 @@ export const getSortedAndFilteredData = ( if (spaceTerms.length > 1) { // For OR search, at least one term must match return spaceTerms.some(spaceTerm => { - return matchesTerm(guest, spaceTerm, nodeData); + return matchesTerm(guest, spaceTerm, nodeData, metricsData); }); } @@ -168,19 +168,76 @@ export const getSortedAndFilteredData = ( const orTerms = termLower.split('|').map(t => t.trim()).filter(t => t); // For OR search, at least one term must match return orTerms.some(orTerm => { - return matchesTerm(guest, orTerm, nodeData); + return matchesTerm(guest, orTerm, nodeData, metricsData); }); } // Regular term matching (single term) - return matchesTerm(guest, termLower, nodeData); + return matchesTerm(guest, termLower, nodeData, metricsData); }); }); } } // Function to check if a guest matches a single term - function matchesTerm(guest, termLower, nodeData) { + function matchesTerm(guest, termLower, nodeData, metricsData) { + // CHECK FOR PARTIAL EXPRESSIONS FIRST - Don't filter when user is typing an incomplete expression + // This handles cases like 'cpu>' which shouldn't filter anything yet + if (/^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)$/i.test(termLower)) { + // Return true for all items to show everything while the user is still typing + return true; + } + + // Check for resource metric expressions like cpu>50, memory<20, etc. + const resourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i; + const match = termLower.match(resourceExpressionRegex); + + if (match) { + // Extract the resource type and handle mem/memory correctly + let resource = match[1].toLowerCase(); + // If it's 'mem', treat it as 'memory' + if (resource === 'mem') { + resource = 'memory'; + } + + const operator = match[match.length - 2]; // The operator will be the second-to-last matched group + const valueStr = match[match.length - 1]; // The value will be the last matched group + const value = parseFloat(valueStr); + + console.log(`Resource expression match: ${resource} ${operator} ${value}`); // Debug output + + // Get the appropriate metric based on the resource type + let metricValue = null; + + if (resource === 'cpu') { + metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0; + } else if (resource === 'memory') { + metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0; + } else if (resource === 'disk') { + metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0; + } else if (resource === 'network' || resource === 'net') { + // Combine both in and out rates for 'network' + const inRate = metricsData?.network?.[guest.id]?.inRate ?? 0; + const outRate = metricsData?.network?.[guest.id]?.outRate ?? 0; + metricValue = inRate + outRate; + + // Convert network value to Mbps for easier comparison + metricValue = metricValue / (1024 * 1024 / 8); + } + + console.log(`Metric value for ${guest.name}: ${metricValue}`); // Debug output + + // Compare using the specified operator + switch (operator) { + case '>': return metricValue > value; + case '<': return metricValue < value; + case '>=': return metricValue >= value; + case '<=': return metricValue <= value; + case '=': return metricValue === value; + default: return false; + } + } + // Special handling for exact type searches if (termLower === 'ct' || termLower === 'container') { return guest.type === 'lxc'; @@ -193,9 +250,20 @@ export const getSortedAndFilteredData = ( // Handle column-specific searches (using prefixes) if (termLower.includes(':')) { const [prefix, value] = termLower.split(':', 2); + const prefixTrim = prefix.trim(); - // If there's no value after the colon, just do a regular search + // If there's no value after the colon, show all results for that column type + // This improves the UX when the user is still typing if (!value || value.trim() === '') { + const validPrefixes = ['name', 'id', 'node', 'status', 'type', 'cpu', 'memory', 'mem', 'disk', 'network', 'net']; + + // If it's a valid prefix, don't filter yet - return true to show all results + // This prevents everything from disappearing while the user is typing a filter + if (validPrefixes.includes(prefixTrim)) { + return true; + } + + // Otherwise, do a regular search return searchableText(guest, nodeData).includes(termLower); } @@ -220,12 +288,59 @@ export const getSortedAndFilteredData = ( return guest.type === 'lxc'; } return String(guest.type || '').toLowerCase().includes(searchValue); + case 'cpu': + // Handle cpu: format as cpu>= + const cpuValue = parseFloat(searchValue); + if (!isNaN(cpuValue)) { + const cpuMetric = metricsData?.cpu?.[guest.id]?.usage ?? 0; + return cpuMetric >= cpuValue; + } + return false; + case 'memory': + case 'mem': + // Handle memory: and mem: format as memory>= + const memoryValue = parseFloat(searchValue); + if (!isNaN(memoryValue)) { + const memoryMetric = metricsData?.memory?.[guest.id]?.usagePercent ?? 0; + return memoryMetric >= memoryValue; + } + return false; + case 'disk': + // Handle disk: format as disk>= + const diskValue = parseFloat(searchValue); + if (!isNaN(diskValue)) { + const diskMetric = metricsData?.disk?.[guest.id]?.usagePercent ?? 0; + return diskMetric >= diskValue; + } + return false; + case 'network': + case 'net': + // Handle network: format as network>= + const networkValue = parseFloat(searchValue); + if (!isNaN(networkValue)) { + const inRate = metricsData?.network?.[guest.id]?.inRate ?? 0; + const outRate = metricsData?.network?.[guest.id]?.outRate ?? 0; + const totalRate = (inRate + outRate) / (1024 * 1024 / 8); // Convert to Mbps + return totalRate >= networkValue; + } + return false; default: // If we don't recognize the prefix, treat it as a regular search return searchableText(guest, nodeData).includes(termLower); } } + // Special handling for pure numeric IDs - must match exactly + if (/^\d+$/.test(termLower)) { + const guestId = extractNumericId(guest.id); + // For single digits (1-9), treat as a prefix match for better UX while typing + if (termLower.length <= 2) { + return guestId.startsWith(termLower); + } + // For longer numbers, require exact match + return guestId === termLower; + } + // For regular searches, check if the term is in the searchable text return searchableText(guest, nodeData).includes(termLower); } @@ -244,7 +359,11 @@ export const getSortedAndFilteredData = ( guest.type === 'qemu' ? 'vm virtual machine' : '', guest.type === 'lxc' ? 'ct container' : '', // Include status labels for better searching - guest.status?.toLowerCase() === 'running' ? 'online active' : 'offline inactive stopped' + guest.status?.toLowerCase() === 'running' ? 'online active' : 'offline inactive stopped', + // Include resource metrics for easy finding + `cpu ${metricsData?.cpu?.[guest.id]?.usage ?? 0}`, + `memory ${metricsData?.memory?.[guest.id]?.usagePercent ?? 0}`, + `disk ${metricsData?.disk?.[guest.id]?.usagePercent ?? 0}` ].map(val => String(val).toLowerCase()).join(' '); } @@ -295,6 +414,16 @@ export const getSortedAndFilteredData = ( // Apply sorting if (sortConfig) { + // Debug logging for sorting + console.log('Applying sort:', sortConfig, 'to', filteredData.length, 'items'); + console.log('Has metrics data?', metricsData ? 'Yes' : 'No', + metricsData ? { + cpu: Object.keys(metricsData.cpu || {}).length, + memory: Object.keys(metricsData.memory || {}).length, + disk: Object.keys(metricsData.disk || {}).length, + network: Object.keys(metricsData.network || {}).length + } : 'None'); + filteredData.sort((a, b) => { // Handle special cases for metrics-based sorting if (sortConfig.key === 'cpu') { @@ -421,48 +550,36 @@ export const getSortedAndFilteredData = ( /** * Calculate dynamic column widths based on visible columns * @param {Object} columnVisibility - Object containing column visibility state - * @returns {Object} - Object with column IDs as keys and percentage width strings as values + * @returns {Object} - Object with column IDs as keys and pixel values as values */ export const calculateDynamicColumnWidths = (columnVisibility) => { - // Base widths (adjusted to be more space conservative) - const baseWidths = { - node: 7, // Reduced from 8% - type: 3, // Reduced from 4% to make it more compact - id: 6, // Reduced from 7% - status: 3, // Small width since it's only an icon now - name: 15, // Increased slightly to use the space saved from type - cpu: 13, // Reduced from 15% - memory: 13, // Reduced from 15% - disk: 13, // Reduced from 15% - download: 9, // Reduced from 10% - upload: 9, // Reduced from 10% - uptime: 8 // Increased from 7% to accommodate longer uptime strings + const defaultWidths = { + node: 140, // Moderate - node names + type: 30, // Very small - just "VM" or "CT" + id: 40, // Very small - just numeric IDs + status: 30, // Minimal - just an icon + name: 250, // Large - typically longer text + cpu: 90, // Medium - progress bar with percentage + memory: 120, // Larger - progress bar with byte values + disk: 120, // Larger - progress bar with byte values + download: 80, // Medium - network rates + upload: 80, // Medium - network rates + uptime: 70 // Medium - time display }; - // Get total width of visible columns + // Get visible columns const visibleColumns = Object.keys(columnVisibility).filter(key => columnVisibility[key].visible); // If no columns are visible, return default widths - // This prevents issues when all columns are hidden if (visibleColumns.length === 0) { - // Return a default object with all columns at their base widths - // This ensures the table doesn't break when no columns are visible - const defaultWidths = {}; - Object.keys(baseWidths).forEach(key => { - defaultWidths[key] = `${baseWidths[key]}%`; - }); - return defaultWidths; + // Return default pixel values instead of percentages + return { ...defaultWidths }; } - const totalBaseWidth = visibleColumns.reduce((sum, key) => sum + baseWidths[key], 0); - - // Calculate scaling factor to make total width 100% - const scalingFactor = totalBaseWidth > 0 ? 100 / totalBaseWidth : 1; - - // Calculate adjusted widths + // Return the raw pixel values for each column - no percentage conversion const adjustedWidths = {}; visibleColumns.forEach(key => { - adjustedWidths[key] = `${baseWidths[key] * scalingFactor}%`; + adjustedWidths[key] = defaultWidths[key]; }); return adjustedWidths;