mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
Update network components and hooks to support new search and filter functionality
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -1078,7 +1078,6 @@ const useSocket = (url) => {
|
||||
}, [isConnected]);
|
||||
|
||||
return {
|
||||
socket: socketRef.current,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
error,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user