Refactor NetworkDisplay component into smaller, more manageable files with custom hooks and components

This commit is contained in:
courtmanr@gmail.com
2025-03-09 12:34:13 +00:00
parent 7a968763f1
commit 41fa06b3fa
15 changed files with 1951 additions and 1434 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
import React from 'react';
import {
Box,
Typography,
IconButton,
Badge
} from '@mui/material';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
import SearchIcon from '@mui/icons-material/Search';
import ComputerIcon from '@mui/icons-material/Computer';
import DnsIcon from '@mui/icons-material/Dns';
import ViewListIcon from '@mui/icons-material/ViewList';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import TuneIcon from '@mui/icons-material/Tune';
import { DEFAULT_COLUMN_CONFIG } from '../../../constants/networkConstants';
const NetworkHeader = ({
openColumnMenu,
openSearch,
openType,
openVisibility,
openFilters,
activeSearchTerms,
guestTypeFilter,
showStopped,
filters,
columnVisibility,
handleColumnMenuOpen,
handleSearchButtonClick,
handleTypeButtonClick,
handleVisibilityButtonClick,
handleFilterButtonClick,
searchButtonRef,
filterButtonRef
}) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" component="h2" gutterBottom>
Systems
</Typography>
<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}
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>
{/* System type button */}
<IconButton
onClick={handleTypeButtonClick}
color={openType ? 'primary' : 'default'}
size="small"
aria-controls={openType ? 'type-menu' : undefined}
aria-haspopup="true"
aria-expanded={openType ? 'true' : undefined}
sx={{
border: '1px solid',
borderColor: openType ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5
}}
>
<Badge
badgeContent={guestTypeFilter !== 'all' ? 1 : 0}
color="primary"
invisible={guestTypeFilter === 'all'}
>
{guestTypeFilter === 'vm' ? <ComputerIcon /> :
guestTypeFilter === 'ct' ? <DnsIcon /> :
<ViewListIcon />}
</Badge>
</IconButton>
{/* Visibility button */}
<IconButton
onClick={handleVisibilityButtonClick}
color={openVisibility ? 'primary' : 'default'}
size="small"
aria-controls={openVisibility ? 'visibility-menu' : undefined}
aria-haspopup="true"
aria-expanded={openVisibility ? 'true' : undefined}
sx={{
border: '1px solid',
borderColor: openVisibility ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5
}}
>
{showStopped ? <VisibilityIcon /> : <VisibilityOffIcon />}
</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>
</Box>
);
};
export default NetworkHeader;
@@ -0,0 +1,29 @@
import React from 'react';
import { Snackbar, Alert } from '@mui/material';
const NetworkNotification = ({
snackbarOpen,
snackbarMessage,
snackbarSeverity,
handleSnackbarClose
}) => {
return (
<Snackbar
open={snackbarOpen}
autoHideDuration={4000}
onClose={handleSnackbarClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleSnackbarClose}
severity={snackbarSeverity}
variant="filled"
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
);
};
export default NetworkNotification;
@@ -0,0 +1,531 @@
import React from 'react';
import {
Popover,
Paper,
Box,
Typography,
TextField,
IconButton,
Chip,
Button,
ToggleButtonGroup,
ToggleButton,
MenuItem,
Switch,
Slider
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import ComputerIcon from '@mui/icons-material/Computer';
import DnsIcon from '@mui/icons-material/Dns';
import ViewListIcon from '@mui/icons-material/ViewList';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
const NetworkPopovers = ({
// Search popover props
searchAnchorEl,
openSearch,
handleCloseSearchPopover,
searchTerm,
setSearchTerm,
activeSearchTerms,
addSearchTerm,
removeSearchTerm,
searchInputRef,
// Type popover props
typeAnchorEl,
openType,
handleCloseTypePopover,
guestTypeFilter,
setGuestTypeFilter,
// Visibility popover props
visibilityAnchorEl,
openVisibility,
handleCloseVisibilityPopover,
showStopped,
setShowStopped,
// Filter popover props
filterAnchorEl,
openFilters,
handleCloseFilterPopover,
filters,
updateFilter,
handleSliderDragStart,
handleSliderDragEnd,
resetFilters,
// Formatters
formatPercentage,
formatNetworkRateForFilter
}) => {
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()) {
addSearchTerm(searchTerm.trim());
setSearchTerm('');
}
}}
sx={{ display: 'flex', alignItems: 'center' }}
>
<SearchIcon sx={{ color: 'text.secondary', mr: 1 }} />
<TextField
fullWidth
size="small"
placeholder="Search systems..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchTerm.trim()) {
e.preventDefault();
addSearchTerm(searchTerm.trim());
setSearchTerm('');
}
}}
inputRef={searchInputRef}
autoFocus
variant="standard"
InputProps={{
disableUnderline: true,
endAdornment: searchTerm ? (
<IconButton
size="small"
aria-label="clear search"
onClick={() => setSearchTerm('')}
sx={{ p: 0.5 }}
>
<ClearIcon fontSize="small" />
</IconButton>
) : null
}}
sx={{ flex: 1 }}
/>
</Box>
{activeSearchTerms.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" gutterBottom>
Active Search Terms
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{activeSearchTerms.map((term) => (
<Chip
key={term}
label={term}
size="small"
onDelete={() => removeSearchTerm(term)}
/>
))}
</Box>
</Box>
)}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
onClick={() => {
setActiveSearchTerms([]);
setSearchTerm('');
handleCloseSearchPopover();
}}
>
Clear All
</Button>
</Box>
</Box>
</Paper>
</Popover>
{/* System type popover */}
<Popover
id="type-menu"
anchorEl={typeAnchorEl}
open={openType}
onClose={handleCloseTypePopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
overflow: 'hidden'
}
}}
>
<Paper sx={{ width: 300, p: 0, overflow: 'hidden' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
System Type
</Typography>
<Typography variant="caption" color="text.secondary">
Filter systems by their type.
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<ToggleButtonGroup
value={guestTypeFilter}
exclusive
onChange={(event, newValue) => {
if (newValue !== null) {
setGuestTypeFilter(newValue);
handleCloseTypePopover();
}
}}
aria-label="system type filter"
size="small"
sx={{
width: '100%',
'& .MuiToggleButton-root': {
borderRadius: 1.5,
py: 1,
textTransform: 'none',
fontWeight: 500,
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.dark',
},
'& .MuiSvgIcon-root': {
color: 'primary.contrastText'
}
},
'&:hover': {
backgroundColor: 'action.hover'
}
},
'& .MuiToggleButtonGroup-grouped': {
mx: 0.5,
border: '1px solid',
borderColor: 'divider',
'&:not(:first-of-type)': {
borderLeft: '1px solid',
borderLeftColor: 'divider',
},
'&:first-of-type': {
ml: 0
},
'&:last-of-type': {
mr: 0
}
}
}}
>
<ToggleButton value="all" aria-label="all systems" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ViewListIcon fontSize="small" sx={{ mr: 1 }} />
All Systems
</Box>
</ToggleButton>
<ToggleButton value="vm" aria-label="virtual machines only" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ComputerIcon fontSize="small" sx={{ mr: 1 }} />
VMs Only
</Box>
</ToggleButton>
<ToggleButton value="ct" aria-label="containers only" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<DnsIcon fontSize="small" sx={{ mr: 1 }} />
CTs Only
</Box>
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Paper>
</Popover>
{/* Visibility popover */}
<Popover
id="visibility-menu"
anchorEl={visibilityAnchorEl}
open={openVisibility}
onClose={handleCloseVisibilityPopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
overflow: 'hidden'
}
}}
>
<Paper sx={{ width: 300, p: 0, overflow: 'hidden' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
System Visibility
</Typography>
<Typography variant="caption" color="text.secondary">
Control which systems are visible.
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<MenuItem dense onClick={() => {
setShowStopped(!showStopped);
handleCloseVisibilityPopover();
}}
sx={{
borderRadius: 1.5,
'&:hover': {
backgroundColor: 'action.hover'
}
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between'
}}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{showStopped ? (
<VisibilityIcon fontSize="small" color="primary" sx={{ mr: 1 }} />
) : (
<VisibilityOffIcon fontSize="small" color="action" sx={{ mr: 1 }} />
)}
<Typography variant="body2">
{showStopped ? 'Hide Stopped Systems' : 'Show Stopped Systems'}
</Typography>
</Box>
<Switch
checked={showStopped}
onChange={(e) => {
setShowStopped(e.target.checked);
handleCloseVisibilityPopover();
}}
size="small"
/>
</Box>
</MenuItem>
</Box>
</Paper>
</Popover>
{/* Resource thresholds popover */}
<Popover
id="filter-menu"
anchorEl={filterAnchorEl}
open={openFilters}
onClose={handleCloseFilterPopover}
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>
Resource Thresholds
</Typography>
<Typography variant="caption" color="text.secondary">
Filter systems by resource usage thresholds.
</Typography>
</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>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.cpu)}+
</Typography>
</Box>
<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"
/>
</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>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.memory)}+
</Typography>
</Box>
<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"
/>
</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>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.disk)}+
</Typography>
</Box>
<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"
/>
</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>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.download)}+
</Typography>
</Box>
<Slider
value={filters.download}
onChange={(e, newValue) => updateFilter('download', newValue)}
onMouseDown={() => handleSliderDragStart('download')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="download-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
min={0}
max={100}
size="small"
/>
</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>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.upload)}+
</Typography>
</Box>
<Slider
value={filters.upload}
onChange={(e, newValue) => updateFilter('upload', newValue)}
onMouseDown={() => handleSliderDragStart('upload')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="upload-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
min={0}
max={100}
size="small"
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
variant="outlined"
onClick={() => {
resetFilters();
handleCloseFilterPopover();
}}
startIcon={<FilterAltOffIcon />}
>
Reset All Thresholds
</Button>
</Box>
</Paper>
</Popover>
</>
);
};
export default NetworkPopovers;
@@ -0,0 +1,85 @@
import React from 'react';
import {
Card,
CardContent,
TableContainer,
Table,
Paper
} from '@mui/material';
import NetworkTableHeader from '../NetworkTableHeader';
import NetworkTableBody from '../NetworkTableBody';
const NetworkTable = ({
sortConfig,
requestSort,
columnVisibility,
toggleColumnVisibility,
resetColumnVisibility,
columnMenuAnchorEl,
handleColumnMenuOpen,
handleColumnMenuClose,
openColumnMenu,
forceUpdateCounter,
columnOrder,
setColumnOrder,
activeFilteredColumns,
sortedAndFilteredData,
guestData,
metricsData,
getNodeName,
extractNumericId,
resetFilters,
showStopped,
setShowStopped,
guestTypeFilter
}) => {
return (
<Card>
<CardContent sx={{ p: 0 }}>
<TableContainer component={Paper} sx={{ maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }}>
<Table stickyHeader size="small" sx={{
'& .MuiTableCell-stickyHeader': {
borderBottom: '2px solid',
borderBottomColor: 'divider',
zIndex: 3 // Ensure the header stays above other elements
}
}}>
<NetworkTableHeader
sortConfig={sortConfig}
requestSort={requestSort}
columnVisibility={columnVisibility}
toggleColumnVisibility={toggleColumnVisibility}
resetColumnVisibility={resetColumnVisibility}
columnMenuAnchorEl={columnMenuAnchorEl}
handleColumnMenuOpen={handleColumnMenuOpen}
handleColumnMenuClose={handleColumnMenuClose}
openColumnMenu={openColumnMenu}
forceUpdateCounter={forceUpdateCounter}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
activeFilteredColumns={activeFilteredColumns}
/>
<NetworkTableBody
sortedAndFilteredData={sortedAndFilteredData}
guestData={guestData}
metricsData={metricsData}
columnVisibility={columnVisibility}
getNodeName={getNodeName}
extractNumericId={extractNumericId}
resetFilters={resetFilters}
showStopped={showStopped}
setShowStopped={setShowStopped}
guestTypeFilter={guestTypeFilter}
resetColumnVisibility={resetColumnVisibility}
forceUpdateCounter={forceUpdateCounter}
columnOrder={columnOrder}
activeFilteredColumns={activeFilteredColumns}
/>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
export default NetworkTable;
@@ -0,0 +1,4 @@
export { default as NetworkHeader } from './NetworkHeader';
export { default as NetworkPopovers } from './NetworkPopovers';
export { default as NetworkNotification } from './NetworkNotification';
export { default as NetworkTable } from './NetworkTable';
@@ -0,0 +1,8 @@
export { default as useNetworkFilters } from './useNetworkFilters';
export { default as useColumnManagement } from './useColumnManagement';
export { default as usePopoverManagement } from './usePopoverManagement';
export { default as useNotifications } from './useNotifications';
export { default as useSortManagement } from './useSortManagement';
export { default as useKeyboardShortcuts } from './useKeyboardShortcuts';
export { default as useActiveFilteredColumns } from './useActiveFilteredColumns';
export { default as useDataProcessing } from './useDataProcessing';
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
const useActiveFilteredColumns = ({
filters,
activeSearchTerms,
searchTerm,
guestTypeFilter,
showStopped,
nodeData
}) => {
// Determine which columns have active filters
const activeFilteredColumns = useMemo(() => {
const result = {};
// Resource filters
if (filters.cpu > 0) result.cpu = true;
if (filters.memory > 0) result.memory = true;
if (filters.disk > 0) result.disk = true;
if (filters.download > 0) result.netIn = true;
if (filters.upload > 0) result.netOut = true;
// Search terms - determine which column to highlight based on the search term
if (activeSearchTerms.length > 0 || searchTerm) {
const allTerms = [...activeSearchTerms];
if (searchTerm) allTerms.push(searchTerm);
// Check each term and determine which column(s) to highlight
allTerms.forEach(term => {
const termLower = term.trim().toLowerCase();
// Check for exact type matches first
if (termLower === 'ct' || termLower === 'container') {
result.type = true;
return; // Skip other checks for this term
}
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;
}
});
}
// Guest type filter affects the type column
if (guestTypeFilter !== 'all') {
result.type = true;
}
// Only highlight status column for show/hide stopped systems if it's not the default state
// By default, showStopped is false, so we only highlight when it's true (showing stopped systems)
if (showStopped) {
result.status = true;
}
return result;
}, [filters, activeSearchTerms, searchTerm, guestTypeFilter, showStopped, nodeData]);
return activeFilteredColumns;
};
export default useActiveFilteredColumns;
@@ -0,0 +1,256 @@
import { useState, useCallback, useEffect } from 'react';
import {
STORAGE_KEY_COLUMN_VISIBILITY,
STORAGE_KEY_COLUMN_ORDER,
DEFAULT_COLUMN_CONFIG
} from '../../../constants/networkConstants';
const useColumnManagement = (showNotification) => {
// Column visibility state
const [columnVisibility, setColumnVisibility] = useState(() => {
try {
console.log('Initializing column visibility state');
const saved = localStorage.getItem(STORAGE_KEY_COLUMN_VISIBILITY);
if (saved) {
console.log('Found saved column visibility:', saved);
const parsed = JSON.parse(saved);
// Ensure all columns from DEFAULT_COLUMN_CONFIG exist in the saved config
const merged = { ...DEFAULT_COLUMN_CONFIG };
Object.keys(parsed).forEach(key => {
if (merged[key]) {
merged[key].visible = parsed[key].visible;
}
});
// Ensure at least one column is visible
const hasVisibleColumn = Object.values(merged).some(col => col.visible);
if (!hasVisibleColumn) {
console.warn('No visible columns in saved config, resetting to defaults');
return JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG));
}
console.log('Using merged column visibility:', merged);
return merged;
}
console.log('No saved column visibility, using defaults');
return JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG));
} catch (e) {
console.error('Error loading column visibility preferences:', e);
return JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG));
}
});
// Column order state
const [columnOrder, setColumnOrder] = useState(() => {
try {
console.log('Initializing column order state');
const defaultOrder = Object.keys(DEFAULT_COLUMN_CONFIG);
const saved = localStorage.getItem(STORAGE_KEY_COLUMN_ORDER);
if (saved) {
console.log('Found saved column order:', saved);
try {
const parsed = JSON.parse(saved);
// Validate the saved order - it should contain all columns from DEFAULT_COLUMN_CONFIG
const isValid =
Array.isArray(parsed) &&
parsed.length === defaultOrder.length &&
defaultOrder.every(col => parsed.includes(col));
if (isValid) {
console.log('Using saved column order:', parsed);
return parsed;
} else {
console.warn('Invalid saved column order, using default order');
}
} catch (parseError) {
console.error('Error parsing saved column order:', parseError);
}
}
console.log('Using default column order:', defaultOrder);
return defaultOrder;
} catch (e) {
console.error('Error loading column order preferences:', e);
const fallbackOrder = Object.keys(DEFAULT_COLUMN_CONFIG || {});
console.log('Using fallback column order:', fallbackOrder);
return fallbackOrder;
}
});
// Column settings menu state
const [columnMenuAnchorEl, setColumnMenuAnchorEl] = useState(null);
const openColumnMenu = Boolean(columnMenuAnchorEl);
// State for forcing re-renders
const [forceUpdateCounter, setForceUpdateCounter] = useState(0);
// Function to force a re-render
const forceUpdate = useCallback(() => {
setForceUpdateCounter(prev => prev + 1);
}, []);
// Toggle column visibility
const toggleColumnVisibility = useCallback((columnId) => {
try {
setColumnVisibility(prev => {
if (!prev || !prev[columnId]) {
console.error('Invalid column configuration:', prev);
return prev;
}
// Check if this is the last visible column and we're trying to hide it
const isLastVisibleColumn =
Object.values(prev).filter(col => col.visible).length === 1 &&
prev[columnId].visible;
// If this is the last visible column and we're trying to hide it, don't allow it
if (isLastVisibleColumn) {
// Show a console warning
console.warn('Cannot hide the last visible column');
// Show a notification to the user
setTimeout(() => {
showNotification('At least one column must remain visible', 'warning');
}, 0);
// Return the previous state unchanged
return prev;
}
const updated = {
...prev,
[columnId]: {
...prev[columnId],
visible: !prev[columnId].visible
}
};
return updated;
});
} catch (error) {
console.error('Error toggling column visibility:', error);
}
}, [showNotification]);
// Reset column visibility to defaults
const resetColumnVisibility = useCallback(() => {
console.log('Resetting column visibility to defaults');
// Create a new configuration with all columns set to visible
const allVisibleConfig = {};
if (DEFAULT_COLUMN_CONFIG) {
Object.keys(DEFAULT_COLUMN_CONFIG).forEach(key => {
allVisibleConfig[key] = {
...DEFAULT_COLUMN_CONFIG[key],
visible: true // Force all columns to be visible
};
});
}
console.log('New config with all columns visible:', allVisibleConfig);
// Clear the localStorage entries to ensure a clean state
try {
localStorage.removeItem(STORAGE_KEY_COLUMN_VISIBILITY);
localStorage.removeItem(STORAGE_KEY_COLUMN_ORDER);
console.log('Cleared column preferences from localStorage');
} catch (error) {
console.error('Error clearing column preferences from localStorage:', error);
}
// Set the state with the new config where all columns are visible
setColumnVisibility(allVisibleConfig);
// Reset column order to default
if (DEFAULT_COLUMN_CONFIG) {
setColumnOrder(Object.keys(DEFAULT_COLUMN_CONFIG));
}
// Force a re-render of the table
setTimeout(() => {
forceUpdate();
console.log('Forced table re-render');
}, 50);
// Show a notification to confirm the action
showNotification('All columns are now visible', 'success');
// Close the column menu if it's open
if (openColumnMenu) {
handleColumnMenuClose();
}
}, [openColumnMenu, forceUpdate, showNotification]);
// Handle column menu open
const handleColumnMenuOpen = useCallback((event) => {
setColumnMenuAnchorEl(event.currentTarget);
}, []);
// Handle column menu close
const handleColumnMenuClose = useCallback(() => {
setColumnMenuAnchorEl(null);
}, []);
// Save column visibility preferences whenever they change
useEffect(() => {
try {
console.log('Column visibility changed, saving to localStorage');
// Check if columnVisibility is valid
if (!columnVisibility || Object.keys(columnVisibility).length === 0) {
console.error('Invalid column visibility state, resetting to defaults');
setColumnVisibility(JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG)));
return;
}
// Ensure at least one column is visible
const hasVisibleColumn = Object.values(columnVisibility).some(col => col.visible);
if (!hasVisibleColumn) {
console.error('No visible columns in state, not saving to localStorage');
return;
}
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_VISIBILITY, JSON.stringify(columnVisibility));
console.log('Saved column visibility to localStorage');
} catch (error) {
console.error('Error saving column visibility:', error);
}
}, [columnVisibility]);
// Save column order preferences whenever they change
useEffect(() => {
try {
console.log('Column order changed, saving to localStorage');
// Check if columnOrder is valid
if (!columnOrder || !Array.isArray(columnOrder) || columnOrder.length === 0) {
console.error('Invalid column order state, resetting to defaults');
setColumnOrder(Object.keys(DEFAULT_COLUMN_CONFIG));
return;
}
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_ORDER, JSON.stringify(columnOrder));
console.log('Saved column order to localStorage');
} catch (error) {
console.error('Error saving column order:', error);
}
}, [columnOrder]);
return {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
columnMenuAnchorEl,
openColumnMenu,
forceUpdateCounter,
forceUpdate,
toggleColumnVisibility,
resetColumnVisibility,
handleColumnMenuOpen,
handleColumnMenuClose
};
};
export default useColumnManagement;
@@ -0,0 +1,88 @@
import { useMemo, useCallback } from 'react';
import { getSortedAndFilteredData, getNodeFilteredGuests as nodeFilteredGuestsUtil, getNodeName as getNodeNameUtil, extractNumericId as extractNumericIdUtil } from '../../../utils/networkUtils';
const useDataProcessing = ({
guestData,
combinedMetrics,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
selectedNode,
guestTypeFilter,
nodeData
}) => {
// Helper function to extract numeric ID from strings like "node-1-ct-105"
const extractNumericId = useCallback((fullId) => {
return extractNumericIdUtil(fullId);
}, []);
// Helper function to get the node name from the node ID
const getNodeName = useCallback((nodeId) => {
return getNodeNameUtil(nodeId, nodeData);
}, [nodeData]);
// Filter guests based on selected node
const getNodeFilteredGuests = useCallback((guests) => {
return nodeFilteredGuestsUtil(guests, selectedNode);
}, [selectedNode]);
// Get sorted and filtered data
const sortedAndFilteredData = useMemo(() => {
// First filter by node
const nodeFilteredData = selectedNode === 'all'
? guestData
: getNodeFilteredGuests(guestData);
// Then apply all other filters and sorting
return getSortedAndFilteredData(
nodeFilteredData,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
combinedMetrics,
guestTypeFilter,
nodeData
);
}, [
guestData,
combinedMetrics,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
selectedNode,
getNodeFilteredGuests,
guestTypeFilter,
nodeData
]);
// Format percentage for display
const formatPercentage = useCallback((value) => {
return `${value}%`;
}, []);
// Format network rate for filter display
const formatNetworkRateForFilter = useCallback((value) => {
if (value === 0) return '0 KB/s';
if (value <= 10) return `${value * 10} KB/s`;
if (value <= 50) return `${(value - 10) * 20 + 100} KB/s`;
if (value <= 80) return `${(value - 50) * 50 + 900} KB/s`;
return `${(value - 80) * 500 + 2400} KB/s`;
}, []);
return {
extractNumericId,
getNodeName,
getNodeFilteredGuests,
sortedAndFilteredData,
formatPercentage,
formatNetworkRateForFilter
};
};
export default useDataProcessing;
@@ -0,0 +1,108 @@
import { useState, useEffect, useCallback } from 'react';
const useKeyboardShortcuts = ({
openFilters,
openSearch,
openType,
openVisibility,
openColumnMenu,
resetFilters,
closeAllPopovers,
handleSearchButtonClick,
searchButtonRef,
setSearchTerm,
showNotification
}) => {
const [escRecentlyPressed, setEscRecentlyPressed] = useState(false);
// Set up keyboard shortcuts
useEffect(() => {
const handleGlobalKeyDown = (e) => {
// Immediately return if search popover is open
if (openSearch) {
return;
}
// Don't trigger shortcuts if typing in an input field
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
) {
return;
}
// Escape key to close filters or clear search
if (e.key === 'Escape') {
// Close any open popovers
closeAllPopovers();
// Reset all filters
resetFilters();
// Set flag to prevent other shortcuts from triggering
setEscRecentlyPressed(true);
setTimeout(() => setEscRecentlyPressed(false), 300);
// Show notification
showNotification('All filters have been cleared', 'info');
return;
}
// Ctrl+F or Cmd+F to focus search
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
handleSearchButtonClick({ currentTarget: searchButtonRef.current });
return;
}
// / to focus search
if (e.key === '/' && !escRecentlyPressed) {
e.preventDefault();
handleSearchButtonClick({ currentTarget: searchButtonRef.current });
return;
}
// Capture typing for search (only if it's a single printable character)
const isPrintableChar =
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.key.match(/^F\d+$/); // Exclude function keys like F1-F12
if (isPrintableChar && !openSearch) {
// Open search popover and set the search term to the pressed key
handleSearchButtonClick({ currentTarget: searchButtonRef.current });
// Set a small timeout to ensure the search input is ready
setTimeout(() => {
setSearchTerm(e.key);
}, 50);
}
};
window.addEventListener('keydown', handleGlobalKeyDown);
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown);
};
}, [
openFilters,
openSearch,
openType,
openVisibility,
openColumnMenu,
escRecentlyPressed,
resetFilters,
closeAllPopovers,
handleSearchButtonClick,
searchButtonRef,
setSearchTerm,
showNotification
]);
return {
escRecentlyPressed
};
};
export default useKeyboardShortcuts;
@@ -0,0 +1,193 @@
import { useState, useCallback, useEffect } from 'react';
import {
STORAGE_KEY_FILTERS,
STORAGE_KEY_SHOW_STOPPED,
STORAGE_KEY_SHOW_FILTERS,
STORAGE_KEY_SEARCH_TERMS,
STORAGE_KEY_GUEST_TYPE_FILTER
} from '../../../constants/networkConstants';
const useNetworkFilters = () => {
// Filter states
const [filters, setFilters] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
return saved ? JSON.parse(saved) : {
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
};
} catch (e) {
console.error('Error loading filter preferences:', e);
return {
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
};
}
});
// UI state
const [showStopped, setShowStopped] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SHOW_STOPPED);
return saved ? JSON.parse(saved) === true : false; // Default: false (show only running systems)
} catch (e) {
console.error('Error loading show stopped preference:', e);
return false; // Default to showing only running systems
}
});
const [showFilters, setShowFilters] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SHOW_FILTERS);
return saved ? JSON.parse(saved) === true : false;
} catch (e) {
console.error('Error loading show filters preference:', e);
return false;
}
});
// 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 {
const saved = localStorage.getItem(STORAGE_KEY_GUEST_TYPE_FILTER);
return saved ? JSON.parse(saved) : 'all'; // Default: 'all' (show both VMs and LXCs)
} catch (e) {
console.error('Error loading guest type filter preference:', e);
return 'all'; // Default to showing all guest types
}
});
// State for tracking which slider is being dragged
const [sliderDragging, setSliderDragging] = useState(null);
// Save guest type filter preference whenever it changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY_GUEST_TYPE_FILTER, JSON.stringify(guestTypeFilter));
} catch (e) {
console.error('Error saving guest type filter preference:', e);
}
}, [guestTypeFilter]);
// Save show stopped preference whenever it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SHOW_STOPPED, JSON.stringify(showStopped));
}, [showStopped]);
// Save show filters preference whenever it changes
useEffect(() => {
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]);
}
}, [activeSearchTerms]);
// Function to remove a search term
const removeSearchTerm = useCallback((term) => {
setActiveSearchTerms(prev => prev.filter(t => t !== term));
}, []);
// Update filter value
const updateFilter = useCallback((filterName, newValue) => {
setFilters(prev => ({
...prev,
[filterName]: newValue
}));
}, []);
// Handle slider drag start
const handleSliderDragStart = useCallback((filterName) => {
setSliderDragging(filterName);
}, []);
// Handle slider drag end
const handleSliderDragEnd = useCallback(() => {
setSliderDragging(null);
}, []);
// Clear a specific filter
const clearFilter = useCallback((filterName) => {
setFilters(prev => ({
...prev,
[filterName]: 0
}));
}, []);
// Function to reset all filters
const resetFilters = useCallback(() => {
setFilters({
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
});
setActiveSearchTerms([]);
setSearchTerm('');
setShowStopped(false);
setGuestTypeFilter('all');
}, []);
// Count active filters
const activeFilterCount = activeSearchTerms.length +
(searchTerm && !activeSearchTerms.includes(searchTerm) ? 1 : 0) +
Object.values(filters).filter(val => val > 0).length;
return {
filters,
setFilters,
showStopped,
setShowStopped,
showFilters,
setShowFilters,
searchTerm,
setSearchTerm,
activeSearchTerms,
setActiveSearchTerms,
guestTypeFilter,
setGuestTypeFilter,
sliderDragging,
addSearchTerm,
removeSearchTerm,
updateFilter,
handleSliderDragStart,
handleSliderDragEnd,
clearFilter,
resetFilters,
activeFilterCount
};
};
export default useNetworkFilters;
@@ -0,0 +1,33 @@
import { useState, useCallback } from 'react';
const useNotifications = () => {
// Snackbar state for notifications
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarSeverity, setSnackbarSeverity] = useState('info');
// Handle snackbar close
const handleSnackbarClose = useCallback((event, reason) => {
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
}, []);
// Show a notification
const showNotification = useCallback((message, severity = 'info') => {
setSnackbarMessage(message);
setSnackbarSeverity(severity);
setSnackbarOpen(true);
}, []);
return {
snackbarOpen,
snackbarMessage,
snackbarSeverity,
handleSnackbarClose,
showNotification
};
};
export default useNotifications;
@@ -0,0 +1,101 @@
import { useState, useRef, useCallback } from 'react';
const usePopoverManagement = () => {
// State for filter menu
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);
// State for type popover
const [typeAnchorEl, setTypeAnchorEl] = useState(null);
const openType = Boolean(typeAnchorEl);
// State for visibility popover
const [visibilityAnchorEl, setVisibilityAnchorEl] = useState(null);
const openVisibility = Boolean(visibilityAnchorEl);
// 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);
}, []);
const handleCloseFilterPopover = useCallback(() => {
setFilterAnchorEl(null);
}, []);
// Search popover handlers
const handleSearchButtonClick = useCallback((event) => {
setSearchAnchorEl(event.currentTarget);
// Focus the search input after a short delay to ensure the popover is open
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, 100);
}, []);
const handleCloseSearchPopover = useCallback(() => {
setSearchAnchorEl(null);
}, []);
// System type popover handlers
const handleTypeButtonClick = useCallback((event) => {
setTypeAnchorEl(event.currentTarget);
}, []);
const handleCloseTypePopover = useCallback(() => {
setTypeAnchorEl(null);
}, []);
// Visibility popover handlers
const handleVisibilityButtonClick = useCallback((event) => {
setVisibilityAnchorEl(event.currentTarget);
}, []);
const handleCloseVisibilityPopover = useCallback(() => {
setVisibilityAnchorEl(null);
}, []);
// Function to close all popovers
const closeAllPopovers = useCallback(() => {
setFilterAnchorEl(null);
setSearchAnchorEl(null);
setTypeAnchorEl(null);
setVisibilityAnchorEl(null);
}, []);
return {
filterAnchorEl,
openFilters,
searchAnchorEl,
openSearch,
searchInputRef,
typeAnchorEl,
openType,
visibilityAnchorEl,
openVisibility,
filterButtonRef,
searchButtonRef,
handleFilterButtonClick,
handleCloseFilterPopover,
handleSearchButtonClick,
handleCloseSearchPopover,
handleTypeButtonClick,
handleCloseTypePopover,
handleVisibilityButtonClick,
handleCloseVisibilityPopover,
closeAllPopovers
};
};
export default usePopoverManagement;
@@ -0,0 +1,36 @@
import { useState, useCallback, useEffect } from 'react';
import { STORAGE_KEY_SORT } from '../../../constants/networkConstants';
const useSortManagement = () => {
// Sort state
const [sortConfig, setSortConfig] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SORT);
return saved ? JSON.parse(saved) : { key: 'id', direction: 'asc' };
} catch (e) {
console.error('Error loading sort preferences:', e);
return { key: 'id', direction: 'asc' };
}
});
// Request sort by key
const requestSort = useCallback((key) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
}));
}, []);
// Save sort preferences whenever they change
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SORT, JSON.stringify(sortConfig));
}, [sortConfig]);
return {
sortConfig,
setSortConfig,
requestSort
};
};
export default useSortManagement;