Update network components and hooks to support new search and filter functionality

This commit is contained in:
courtmanr@gmail.com
2025-03-15 19:58:32 +00:00
parent ded558e149
commit b9f2def4ea
14 changed files with 1135 additions and 853 deletions
@@ -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(
<Tooltip title={hiddenColumnsCount > 0 ? `Show hidden columns (${hiddenColumnsCount})` : "Column visibility settings"}>
<IconButton
onClick={handleColumnMenuOpen}
color={openColumnMenu ? 'primary' : 'default'}
size="small"
aria-controls={openColumnMenu ? 'column-menu' : undefined}
aria-haspopup="true"
aria-expanded={openColumnMenu ? 'true' : undefined}
sx={{
borderRadius: 1,
p: 0.5,
bgcolor: hiddenColumnsCount > 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)'
}
}}
>
<Badge
badgeContent={hiddenColumnsCount}
color="error"
invisible={hiddenColumnsCount === 0}
>
<ViewColumnIcon />
</Badge>
</IconButton>
</Tooltip>,
portalRoot
);
};
// Show loading state if not connected
if (!isConnected) {
if (error || connectionStatus === 'disconnected' || connectionStatus === 'error') {
@@ -524,35 +673,24 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => {
return (
<Box sx={{ width: '100%' }}>
{/* Header with buttons */}
<NetworkHeader
openColumnMenu={openColumnMenu}
openSearch={openSearch}
openFilters={openFilters}
activeSearchTerms={activeSearchTerms}
filters={filters}
columnVisibility={columnVisibility}
handleColumnMenuOpen={handleColumnMenuOpen}
handleSearchButtonClick={handleSearchButtonClick}
handleFilterButtonClick={handleFilterButtonClick}
searchButtonRef={searchButtonRef}
filterButtonRef={filterButtonRef}
/>
{/* Render the column visibility button portal */}
{renderColumnVisibilityPortal()}
{/* Column Menu - Ensure it's still rendered to handle the actual menu */}
<Box sx={{ position: 'absolute', top: 0, left: 0, visibility: 'hidden' }}>
{openColumnMenu && (
<span
ref={(node) => {
if (node && !columnMenuAnchorEl) {
handleColumnMenuOpen({ currentTarget: node });
}
}}
/>
)}
</Box>
{/* Popovers */}
<NetworkPopovers
// Search popover props
searchAnchorEl={searchAnchorEl}
openSearch={openSearch}
handleCloseSearchPopover={handleCloseSearchPopover}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
activeSearchTerms={activeSearchTerms}
addSearchTerm={addSearchTerm}
removeSearchTerm={removeSearchTerm}
searchInputRef={searchInputRef}
clearSearchTerms={clearSearchTerms}
// Filter popover props
filterAnchorEl={filterAnchorEl}
openFilters={openFilters}
@@ -561,7 +699,7 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => {
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 */}
@@ -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 = ({
<InputBase
placeholder="Press Enter to search..."
value={searchTerm}
onChange={(e) => 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 = ({
<IconButton
size="small"
aria-label="clear search"
onClick={() => setSearchTerm('')}
onClick={() => {
setSearchTerm('');
searchInputRef.current?.focus();
}}
sx={{ p: 0.5 }}
>
<ClearIcon fontSize="small" />
@@ -237,16 +259,22 @@ const NetworkFilters = ({
</Typography>
{/* CPU Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.cpu > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color={filters.cpu > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}>
CPU Usage
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" color={filters.cpu > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}>
{formatPercentage(filters.cpu)}+
</Typography>
</Box>
<Slider
id="cpu-filter-slider"
value={filters.cpu}
onChange={(e, newValue) => 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,
}
}}
/>
</Box>
{/* Memory Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.memory > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color={filters.memory > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}>
Memory Usage
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" color={filters.memory > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}>
{formatPercentage(filters.memory)}+
</Typography>
</Box>
<Slider
id="memory-filter-slider"
value={filters.memory}
onChange={(e, newValue) => 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,
}
}}
/>
</Box>
{/* Disk Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.disk > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color={filters.disk > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}>
Disk Usage
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" color={filters.disk > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}>
{formatPercentage(filters.disk)}+
</Typography>
</Box>
<Slider
id="disk-filter-slider"
value={filters.disk}
onChange={(e, newValue) => 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,
}
}}
/>
</Box>
{/* Network Download Filter */}
<Box sx={{ mb: 2 }}>
{/* Download Filter */}
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.download > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color={filters.download > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}>
Download Rate
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" color={filters.download > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}>
{formatNetworkRateForFilter(filters.download)}+
</Typography>
</Box>
<Slider
id="download-filter-slider"
value={filters.download}
onChange={(e, newValue) => 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,
}
}}
/>
</Box>
{/* Network Upload Filter */}
<Box sx={{ mb: 2 }}>
{/* Upload Filter */}
<Box sx={{
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: filters.upload > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color={filters.upload > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}>
Upload Rate
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" color={filters.upload > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}>
{formatNetworkRateForFilter(filters.upload)}+
</Typography>
</Box>
<Slider
id="upload-filter-slider"
value={filters.upload}
onChange={(e, newValue) => 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,
}
}}
/>
</Box>
</Box>
@@ -365,10 +447,7 @@ const NetworkFilters = ({
size="small"
fullWidth
variant="outlined"
onClick={() => {
resetFilters();
handleClose && handleClose();
}}
onClick={handleResetAllFilters}
startIcon={<FilterAltOffIcon />}
>
Reset All Filters
@@ -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 = ({
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 2 }}>
<VisibilityOffIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2, opacity: 0.6 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
No Columns Visible
<VisibilityOffIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
No columns are visible
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ maxWidth: 400, mb: 3 }}>
All columns are currently hidden. Click the button below to reset column visibility.
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Use the column visibility button to show some columns
</Typography>
<Button
variant="outlined"
onClick={resetColumnVisibility}
startIcon={<VisibilityOffIcon />}
>
Reset Column Visibility
Reset Columns
</Button>
</Box>
</TableCell>
</TableRow>
</TableBody>
);
}
// If no data matches the filters, show a message
if (sortedAndFilteredData.length === 0) {
return (
<TableBody sx={{ '& tr:last-child td': { borderBottom: 0 } }}>
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 2 }}>
<FilterAltIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
No matching guests found
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Try adjusting your filters or search terms
</Typography>
<Button
variant="outlined"
onClick={resetFilters}
startIcon={<FilterAltIcon />}
>
Reset Filters
</Button>
</Box>
</TableCell>
@@ -67,82 +130,85 @@ const NetworkTableBody = ({
);
}
// If no data is available, show a message
if (!sortedAndFilteredData || sortedAndFilteredData.length === 0) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 2 }}>
<NetworkCheckIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2, opacity: 0.6 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
No Systems Found
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ maxWidth: 400, mb: 3 }}>
{!showStopped ? (
<>
No running systems match your current filters.
{guestData && guestData.length > 0 && (
<>
<br />
Try showing stopped systems or adjusting your filters.
</>
)}
</>
) : (
<>
No stopped systems match your current filters.
{guestData && guestData.length > 0 && (
<>
<br />
Try showing running systems or adjusting your filters.
</>
)}
</>
)}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
{guestData && (
<Button
variant="outlined"
onClick={() => setShowStopped(!showStopped)}
size="small"
>
{showStopped ? "Show Running Systems" : "Show Stopped Systems"}
</Button>
)}
<Button
variant="outlined"
onClick={resetFilters}
startIcon={<FilterAltIcon />}
size="small"
>
Reset Filters
</Button>
</Box>
</Box>
</TableCell>
</TableRow>
</TableBody>
);
}
// 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 (
<TableBody>
{Array.isArray(sortedAndFilteredData) && sortedAndFilteredData.map((guest) => (
guest && (
<NetworkTableRow
key={`${guest.node || 'unknown'}-${guest.id || 'unknown'}`}
guest={guest}
metrics={metricsData || {}}
columnVisibility={columnVisibility || {}}
getNodeName={getNodeName || (() => 'Unknown')}
extractNumericId={extractNumericId || ((id) => id)}
columnOrder={columnOrder || []}
activeFilteredColumns={activeFilteredColumns}
/>
)
))}
<TableBody sx={{
'& tr:last-child td': { borderBottom: 0 },
'& .MuiTableRow-root': { borderBottom: '1px solid', borderColor: 'divider' }
}}>
{groupedGuests.map((nodeGroup, groupIndex) => {
const { nodeName, guests } = nodeGroup;
return (
<React.Fragment key={`node-${nodeName}`}>
{/* Node Header/Divider */}
<TableRow
sx={{
bgcolor: theme => alpha(theme.palette.primary.main, 0.05),
'&:hover': {
bgcolor: theme => alpha(theme.palette.primary.main, 0.08)
}
}}
>
<TableCell
colSpan={visibleColumnCount}
sx={{
py: 0.75,
px: 2,
borderBottom: '2px solid',
borderTop: groupIndex > 0 ? '2px solid' : 'none',
borderColor: theme => alpha(theme.palette.divider, 0.8)
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
color: theme => {
const color = getNodeTextColor(nodeName, theme.palette.mode);
return color;
}
}}
>
NODE: {nodeName}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 500 }}
>
{guests.length} {guests.length === 1 ? 'guest' : 'guests'}
</Typography>
</Box>
</TableCell>
</TableRow>
{/* Guest Rows */}
{guests.map(guest => (
<NetworkTableRow
key={guest.id}
guest={guest}
metrics={metricsData}
columnVisibility={columnVisibility}
getNodeName={getNodeName}
extractNumericId={extractNumericId}
columnOrder={columnOrder}
activeFilteredColumns={activeFilteredColumns}
thresholdColumn={thresholdColumn}
thresholdValue={thresholdValue}
/>
))}
</React.Fragment>
);
})}
</TableBody>
);
};
@@ -222,4 +222,59 @@ export const KeyboardShortcut = ({ shortcut, sx = {} }) => (
>
{shortcut}
</Box>
);
);
// 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 <Typography variant={variant} sx={sx} {...props}>{text}</Typography>;
}
// 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 <Typography variant={variant} sx={sx} {...props}>{text}</Typography>;
}
return (
<Typography variant={variant} sx={sx} {...props}>
{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 ? (
<Box
component="span"
key={i}
sx={{
backgroundColor: alpha(theme.palette.primary.main, 0.2),
borderRadius: '2px',
padding: '0 2px',
fontWeight: 'medium'
}}
>
{part}
</Box>
) : part;
})}
</Typography>
);
};
@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
{/* Column visibility button */}
<IconButton
onClick={handleColumnMenuOpen}
color={openColumnMenu ? 'primary' : 'default'}
size="small"
aria-controls={openColumnMenu ? 'column-menu' : undefined}
aria-haspopup="true"
aria-expanded={openColumnMenu ? 'true' : undefined}
sx={{
border: '1px solid',
borderColor: openColumnMenu ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5
}}
>
<Badge
badgeContent={Object.keys(DEFAULT_COLUMN_CONFIG).length - Object.values(columnVisibility).filter(col => col.visible).length}
color="primary"
invisible={Object.values(columnVisibility).filter(col => col.visible).length === Object.keys(DEFAULT_COLUMN_CONFIG).length}
>
<ViewColumnIcon />
</Badge>
</IconButton>
{/* Search button */}
<IconButton
ref={searchButtonRef}
onClick={handleSearchButtonClick}
color={openSearch ? 'primary' : 'default'}
size="small"
aria-controls={openSearch ? 'search-menu' : undefined}
aria-haspopup="true"
aria-expanded={openSearch ? 'true' : undefined}
disableRipple={true}
sx={{
border: '1px solid',
borderColor: openSearch ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5
}}
>
<Badge
badgeContent={activeSearchTerms.length}
color="primary"
invisible={activeSearchTerms.length === 0}
>
<SearchIcon />
</Badge>
</IconButton>
{/* Resource thresholds button */}
<IconButton
onClick={handleFilterButtonClick}
color={openFilters ? 'primary' : 'default'}
ref={filterButtonRef}
data-filter-button="true"
size="small"
aria-controls={openFilters ? 'filter-menu' : undefined}
aria-haspopup="true"
aria-expanded={openFilters ? 'true' : undefined}
sx={{
border: '1px solid',
borderColor: openFilters ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5
}}
>
<Badge
badgeContent={
(filters.cpu > 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
}
>
<TuneIcon />
</Badge>
</IconButton>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
mt: 1,
px: 1
}}>
{/* Left side - Intentionally empty, removed Dashboard title */}
<Box />
{/* Right side - Actions - Column visibility button has been moved to the main app header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Additional buttons can be added here if needed */}
</Box>
</Box>
);
@@ -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 */}
<Popover
id="search-menu"
anchorEl={searchAnchorEl}
open={openSearch}
onClose={handleCloseSearchPopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
overflow: 'hidden'
}
}}
>
<Paper sx={{ width: 350, p: 0, overflow: 'hidden' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
Search Systems
</Typography>
<Typography variant="caption" color="text.secondary">
Search for specific systems by name, ID, or other properties.
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<Box
component="form"
onSubmit={(e) => {
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' }}
>
<SearchIcon sx={{ color: 'text.secondary', mr: 1 }} />
<TextField
fullWidth
size="small"
id="searchInput"
placeholder="Press Enter to search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
inputRef={searchInputRef}
autoFocus
variant="outlined"
InputProps={{
endAdornment: searchTerm ? (
<IconButton
size="small"
aria-label="clear search"
onClick={() => setSearchTerm('')}
sx={{ p: 0.5 }}
>
<ClearIcon fontSize="small" />
</IconButton>
) : 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('');
}
}
}}
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
<strong>Search Tips:</strong>
<br /> <strong>Just start typing</strong> anywhere to search
<br /> Use <strong>status:running</strong> or <strong>status:stopped</strong> to filter by status
<br /> Use <strong>type:vm</strong> or <strong>type:ct</strong> for filtering by system type
<br /> Type multiple words for OR search (e.g., <strong>vm server</strong>)
<br /> Add multiple search terms for AND filtering
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
<Chip
label="status:running"
size="small"
variant="outlined"
onClick={() => {
// Don't just set the search term, directly add it as a filter
addSearchTerm('status:running');
}}
sx={{ borderStyle: 'dashed' }}
/>
<Chip
label="status:stopped"
size="small"
variant="outlined"
onClick={() => {
// Don't just set the search term, directly add it as a filter
addSearchTerm('status:stopped');
}}
sx={{ borderStyle: 'dashed' }}
/>
<Chip
label="type:vm"
size="small"
variant="outlined"
onClick={() => {
// Don't just set the search term, directly add it as a filter
addSearchTerm('type:vm');
}}
sx={{ borderStyle: 'dashed' }}
/>
<Chip
label="type:ct"
size="small"
variant="outlined"
onClick={() => {
// Don't just set the search term, directly add it as a filter
addSearchTerm('type:ct');
}}
sx={{ borderStyle: 'dashed' }}
/>
</Box>
</Box>
{activeSearchTerms.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Active Filters:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{activeSearchTerms.map((term) => (
<Chip
key={term}
label={term}
size="small"
onDelete={() => removeSearchTerm(term)}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'& .MuiChip-deleteIcon': {
color: 'primary.contrastText',
opacity: 0.7,
'&:hover': {
opacity: 1
}
}
}}
/>
))}
</Box>
</Box>
)}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
onClick={() => {
clearSearchTerms();
handleCloseSearchPopover();
}}
>
Clear All
</Button>
</Box>
</Box>
</Paper>
</Popover>
{/* Resource thresholds popover */}
{/* Filter popover */}
<Popover
id="filter-menu"
anchorEl={filterAnchorEl}
@@ -269,138 +71,130 @@ const NetworkPopovers = ({
</Box>
<Box sx={{ p: 2 }}>
{/* CPU Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
CPU Usage
</Typography>
{/* CPU filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">CPU Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.cpu)}+
{formatPercentage(filters.cpu)}
</Typography>
</Box>
<Slider
id="cpu-filter-slider"
value={filters.cpu}
onChange={(e, newValue) => 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"
/>
</Box>
{/* Memory Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Memory Usage
</Typography>
{/* Memory filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Memory Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.memory)}+
{formatPercentage(filters.memory)}
</Typography>
</Box>
<Slider
id="memory-filter-slider"
value={filters.memory}
onChange={(e, newValue) => 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"
/>
</Box>
{/* Disk Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Disk Usage
</Typography>
{/* Disk filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Disk Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.disk)}+
{formatPercentage(filters.disk)}
</Typography>
</Box>
<Slider
id="disk-filter-slider"
value={filters.disk}
onChange={(e, newValue) => 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"
/>
</Box>
{/* Network Download Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Download Rate
</Typography>
{/* Download filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Download Rate</Typography>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.download)}+
{formatNetworkRateForFilter(filters.download)}
</Typography>
</Box>
<Slider
id="download-filter-slider"
value={filters.download}
onChange={(e, newValue) => 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"
/>
</Box>
{/* Network Upload Filter */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Upload Rate
</Typography>
{/* Upload filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Upload Rate</Typography>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.upload)}+
{formatNetworkRateForFilter(filters.upload)}
</Typography>
</Box>
<Slider
id="upload-filter-slider"
value={filters.upload}
onChange={(e, newValue) => 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"
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
variant="outlined"
onClick={() => {
resetFilters();
handleCloseFilterPopover();
}}
{/* Reset button */}
<Button
variant="outlined"
startIcon={<FilterAltOffIcon />}
onClick={handleResetAllFilters}
fullWidth
sx={{ mt: 1 }}
>
Reset All Thresholds
Reset All Filters
</Button>
</Box>
</Paper>
@@ -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;
@@ -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
};
@@ -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 {
@@ -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
};
};
@@ -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
};
};
@@ -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));
-1
View File
@@ -1078,7 +1078,6 @@ const useSocket = (url) => {
}, [isConnected]);
return {
socket: socketRef.current,
isConnected,
lastMessage,
error,
+153 -36
View File
@@ -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:<value> format as cpu>=<value>
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:<value> and mem:<value> format as memory>=<value>
const memoryValue = parseFloat(searchValue);
if (!isNaN(memoryValue)) {
const memoryMetric = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
return memoryMetric >= memoryValue;
}
return false;
case 'disk':
// Handle disk:<value> format as disk>=<value>
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:<value> format as network>=<value>
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;