diff --git a/frontend/src/components/network/NetworkDisplay.jsx b/frontend/src/components/network/NetworkDisplay.jsx index 27db1721b..e99f0a893 100644 --- a/frontend/src/components/network/NetworkDisplay.jsx +++ b/frontend/src/components/network/NetworkDisplay.jsx @@ -1,69 +1,30 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo } from 'react'; import useSocket from '../../hooks/useSocket'; import useFormattedMetrics from '../../hooks/useFormattedMetrics'; import useMockMetrics from '../../hooks/useMockMetrics'; import { useThemeContext } from '../../context/ThemeContext'; -import { - Box, - Card, - CardContent, - Typography, - CircularProgress, - Table, - TableContainer, - Paper, - IconButton, - Badge, - ClickAwayListener, - useTheme, - Snackbar, - Alert, - Popover, - InputBase, - Chip, - Button, - ToggleButtonGroup, - ToggleButton, - MenuItem, - Switch, - Slider, - TextField, - Autocomplete -} from '@mui/material'; -import FilterAltIcon from '@mui/icons-material/FilterAlt'; -import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; -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 ClearIcon from '@mui/icons-material/Clear'; +import { Box, CircularProgress, useTheme } from '@mui/material'; -// Import constants +// Import hooks import { - STORAGE_KEY_FILTERS, - STORAGE_KEY_SORT, - STORAGE_KEY_SHOW_STOPPED, - STORAGE_KEY_SHOW_FILTERS, - STORAGE_KEY_SEARCH_TERMS, - STORAGE_KEY_COLUMN_VISIBILITY, - STORAGE_KEY_GUEST_TYPE_FILTER, - DEFAULT_COLUMN_CONFIG, - STORAGE_KEY_COLUMN_ORDER, - STORAGE_KEY_COLUMN_DRAG_ENABLED -} from '../../constants/networkConstants'; + useNetworkFilters, + useColumnManagement, + usePopoverManagement, + useNotifications, + useSortManagement, + useKeyboardShortcuts, + useActiveFilteredColumns, + useDataProcessing +} from './hooks'; // Import components import ConnectionErrorDisplay from './ConnectionErrorDisplay'; -import NetworkFilters from './NetworkFilters'; -import NetworkTableHeader from './NetworkTableHeader'; -import NetworkTableBody from './NetworkTableBody'; - -// Import utilities -import { getSortedAndFilteredData, getNodeFilteredGuests as nodeFilteredGuestsUtil, getNodeName as getNodeNameUtil, extractNumericId as extractNumericIdUtil } from '../../utils/networkUtils'; +import { + NetworkHeader, + NetworkPopovers, + NetworkNotification, + NetworkTable +} from './components'; const NetworkDisplay = ({ selectedNode = 'all' }) => { const { @@ -110,689 +71,124 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { const theme = useTheme(); const { darkMode } = useThemeContext(); - // State for filter menu - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const openFilters = Boolean(filterAnchorEl); + // Use notification hook + const { + snackbarOpen, + snackbarMessage, + snackbarSeverity, + handleSnackbarClose, + showNotification + } = useNotifications(); - // State for search popover - const [searchAnchorEl, setSearchAnchorEl] = useState(null); - const openSearch = Boolean(searchAnchorEl); - 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 []; - } - }); - const searchInputRef = useRef(null); + // Use column management hook + const { + columnVisibility, + columnOrder, + setColumnOrder, + columnMenuAnchorEl, + openColumnMenu, + forceUpdateCounter, + toggleColumnVisibility, + resetColumnVisibility, + handleColumnMenuOpen, + handleColumnMenuClose + } = useColumnManagement(showNotification); - // State for type popover - const [typeAnchorEl, setTypeAnchorEl] = useState(null); - const openType = Boolean(typeAnchorEl); + // Use sort management hook + const { + sortConfig, + requestSort + } = useSortManagement(); - // State for visibility popover - const [visibilityAnchorEl, setVisibilityAnchorEl] = useState(null); - const openVisibility = Boolean(visibilityAnchorEl); - - // State for tracking which slider is being dragged - const [sliderDragging, setSliderDragging] = useState(null); - - // 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]); - - // 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' }; - } - }); - - // 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 - }; - } - }); - - // 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); - - // Snackbar state for notifications - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(''); - const [snackbarSeverity, setSnackbarSeverity] = useState('info'); - - // Handle snackbar close - const handleSnackbarClose = (event, reason) => { - if (reason === 'clickaway') { - return; - } - setSnackbarOpen(false); - }; - - // Show a notification - const showNotification = (message, severity = 'info') => { - setSnackbarMessage(message); - setSnackbarSeverity(severity); - setSnackbarOpen(true); - }; - - // Toggle column visibility - const toggleColumnVisibility = (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); - } - }; - - // State for forcing re-renders - const [forceUpdateCounter, setForceUpdateCounter] = useState(0); - - // Function to force a re-render - const forceUpdate = () => { - setForceUpdateCounter(prev => prev + 1); - }; - - // Reset column visibility to defaults - const resetColumnVisibility = () => { - 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(); - } - }; - - // Handle column menu open - const handleColumnMenuOpen = (event) => { - setColumnMenuAnchorEl(event.currentTarget); - }; - - // Handle column menu close - const handleColumnMenuClose = () => { - 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]); - - // 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; - } - }); - - const [escRecentlyPressed, setEscRecentlyPressed] = useState(false); - - // 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 = (event) => { - setFilterAnchorEl(event.currentTarget); - }; - - const handleCloseFilterPopover = () => { - setFilterAnchorEl(null); - }; - - // Search popover handlers - const handleSearchButtonClick = (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 = () => { - setSearchAnchorEl(null); - }; - - // System type popover handlers - const handleTypeButtonClick = (event) => { - setTypeAnchorEl(event.currentTarget); - }; - - const handleCloseTypePopover = () => { - setTypeAnchorEl(null); - }; - - // Visibility popover handlers - const handleVisibilityButtonClick = (event) => { - setVisibilityAnchorEl(event.currentTarget); - }; - - const handleCloseVisibilityPopover = () => { - setVisibilityAnchorEl(null); - }; - - // Count active filters - const activeFilterCount = useMemo(() => { - return activeSearchTerms.length + - (searchTerm && !activeSearchTerms.includes(searchTerm) ? 1 : 0) + - Object.values(filters).filter(val => val > 0).length; - }, [activeSearchTerms, searchTerm, filters]); - - // 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 - } - }); - - // Filter guests based on selected node - const getNodeFilteredGuests = useCallback((guests) => { - return nodeFilteredGuestsUtil(guests, selectedNode); - }, [selectedNode]); - - // 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 sort preferences whenever they change - useEffect(() => { - localStorage.setItem(STORAGE_KEY_SORT, JSON.stringify(sortConfig)); - }, [sortConfig]); - - // 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 reset all filters - wrap in useCallback to prevent infinite renders - const resetFilters = useCallback(() => { - setFilters({ - cpu: 0, - memory: 0, - disk: 0, - download: 0, - upload: 0 - }); - setActiveSearchTerms([]); - setSearchTerm(''); - setShowStopped(false); - setGuestTypeFilter('all'); - }, []); - - // 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 - if (openFilters) { - setFilterAnchorEl(null); - } - if (openSearch) { - setSearchAnchorEl(null); - } - if (openType) { - setTypeAnchorEl(null); - } - if (openVisibility) { - setVisibilityAnchorEl(null); - } - if (openColumnMenu) { - setColumnMenuAnchorEl(null); - } - - // 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); - }; - }, [ - showFilters, - searchTerm, - activeSearchTerms, - escRecentlyPressed, - openFilters, - openSearch, - openType, - openVisibility, - openColumnMenu, + // Use network filters hook + const { + filters, + showStopped, + setShowStopped, + showFilters, + searchTerm, + setSearchTerm, + activeSearchTerms, + guestTypeFilter, + setGuestTypeFilter, + addSearchTerm, + removeSearchTerm, + updateFilter, + handleSliderDragStart, + handleSliderDragEnd, + clearFilter, resetFilters, - handleSearchButtonClick - ]); + activeFilterCount + } = useNetworkFilters(); - // 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, + // Use popover management hook + const { + filterAnchorEl, + openFilters, + searchAnchorEl, + openSearch, + searchInputRef, + typeAnchorEl, + openType, + visibilityAnchorEl, + openVisibility, + filterButtonRef, + searchButtonRef, + handleFilterButtonClick, + handleCloseFilterPopover, + handleSearchButtonClick, + handleCloseSearchPopover, + handleTypeButtonClick, + handleCloseTypePopover, + handleVisibilityButtonClick, + handleCloseVisibilityPopover, + closeAllPopovers + } = usePopoverManagement(); + + // Use keyboard shortcuts hook + useKeyboardShortcuts({ + openFilters, + openSearch, + openType, + openVisibility, + openColumnMenu, + resetFilters, + closeAllPopovers, + handleSearchButtonClick, + searchButtonRef, + setSearchTerm, + showNotification + }); + + // Use data processing hook + const { + extractNumericId, + getNodeName, + sortedAndFilteredData, + formatPercentage, + formatNetworkRateForFilter + } = useDataProcessing({ + guestData, + combinedMetrics, + sortConfig, + filters, + showStopped, + activeSearchTerms, + searchTerm, + selectedNode, guestTypeFilter, nodeData - ]); + }); - // Format percentage for display - const formatPercentage = (value) => { - return `${value}%`; - }; - - // Format network rate for filter display - const formatNetworkRateForFilter = (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`; - }; - - // 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]); + // Use active filtered columns hook + const activeFilteredColumns = useActiveFilteredColumns({ + filters, + activeSearchTerms, + searchTerm, + guestTypeFilter, + showStopped, + nodeData + }); // Show loading state if not connected if (!isConnected && connectionStatus !== 'error' && connectionStatus !== 'disconnected') { @@ -814,718 +210,104 @@ const NetworkDisplay = ({ selectedNode = 'all' }) => { ); } - // Function to add a search term - const addSearchTerm = (term) => { - if (!activeSearchTerms.includes(term)) { - setActiveSearchTerms([...activeSearchTerms, term]); - } - }; - - // Function to remove a search term - const removeSearchTerm = (term) => { - setActiveSearchTerms(activeSearchTerms.filter(t => t !== term)); - }; - - // Update filter value - const updateFilter = (filterName, newValue) => { - setFilters(prev => ({ - ...prev, - [filterName]: newValue - })); - }; - - // Handle slider drag start - const handleSliderDragStart = (filterName) => { - setSliderDragging(filterName); - }; - - // Handle slider drag end - const handleSliderDragEnd = () => { - setSliderDragging(null); - }; - - // Clear a specific filter - const clearFilter = (filterName) => { - setFilters(prev => ({ - ...prev, - [filterName]: 0 - })); - }; - - // Request sort by key - const requestSort = (key) => { - setSortConfig(prev => ({ - key, - direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc' - })); - }; - return ( - - {/* Header with buttons */} - - - Systems - - - {/* Column visibility button */} - - col.visible).length} - color="primary" - invisible={Object.values(columnVisibility).filter(col => col.visible).length === Object.keys(DEFAULT_COLUMN_CONFIG).length} - > - - - - - {/* Search button */} - - - - - - - {/* System type button */} - - - {guestTypeFilter === 'vm' ? : - guestTypeFilter === 'ct' ? : - } - - - - {/* Visibility button */} - - {showStopped ? : } - - - {/* Resource thresholds button */} - - 0 ? 1 : 0) + - (filters.memory > 0 ? 1 : 0) + - (filters.disk > 0 ? 1 : 0) + - (filters.download > 0 ? 1 : 0) + - (filters.upload > 0 ? 1 : 0) - } - color="primary" - invisible={ - filters.cpu === 0 && - filters.memory === 0 && - filters.disk === 0 && - filters.download === 0 && - filters.upload === 0 - } - > - - - - - + {/* Header with buttons */} + + + {/* Popovers */} + - - - - Search Systems - - - Search for specific systems by name, ID, or other properties. - - - - - { - e.preventDefault(); - if (searchTerm.trim()) { - addSearchTerm(searchTerm.trim()); - setSearchTerm(''); - } - }} - sx={{ display: 'flex', alignItems: 'center' }} - > - - 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 ? ( - setSearchTerm('')} - sx={{ p: 0.5 }} - > - - - ) : null - }} - sx={{ flex: 1 }} - /> - - - {activeSearchTerms.length > 0 && ( - - - Active Search Terms - - - {activeSearchTerms.map((term) => ( - removeSearchTerm(term)} - /> - ))} - - - )} - - - - - - - + // Type popover props + typeAnchorEl={typeAnchorEl} + openType={openType} + handleCloseTypePopover={handleCloseTypePopover} + guestTypeFilter={guestTypeFilter} + setGuestTypeFilter={setGuestTypeFilter} - {/* System type popover */} - - - - - System Type - - - Filter systems by their type. - - - - - { - 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 - } - } - }} - > - - - - All Systems - - - - - - VMs Only - - - - - - CTs Only - - - - - - + // Visibility popover props + visibilityAnchorEl={visibilityAnchorEl} + openVisibility={openVisibility} + handleCloseVisibilityPopover={handleCloseVisibilityPopover} + showStopped={showStopped} + setShowStopped={setShowStopped} - {/* Visibility popover */} - - - - - System Visibility - - - Control which systems are visible. - - - - - { - setShowStopped(!showStopped); - handleCloseVisibilityPopover(); - }} - sx={{ - borderRadius: 1.5, - '&:hover': { - backgroundColor: 'action.hover' - } - }}> - - - {showStopped ? ( - - ) : ( - - )} - - {showStopped ? 'Hide Stopped Systems' : 'Show Stopped Systems'} - - - { - setShowStopped(e.target.checked); - handleCloseVisibilityPopover(); - }} - size="small" - /> - - - - - + // Filter popover props + filterAnchorEl={filterAnchorEl} + openFilters={openFilters} + handleCloseFilterPopover={handleCloseFilterPopover} + filters={filters} + updateFilter={updateFilter} + handleSliderDragStart={handleSliderDragStart} + handleSliderDragEnd={handleSliderDragEnd} + resetFilters={resetFilters} - {/* Resource thresholds popover */} - - - - - Resource Thresholds - - - Filter systems by resource usage thresholds. - - - - - {/* CPU Filter */} - - - - CPU Usage - - - {formatPercentage(filters.cpu)}+ - - - updateFilter('cpu', newValue)} - onMouseDown={() => handleSliderDragStart('cpu')} - onMouseUp={handleSliderDragEnd} - aria-labelledby="cpu-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} - min={0} - max={100} - size="small" - /> - - - {/* Memory Filter */} - - - - Memory Usage - - - {formatPercentage(filters.memory)}+ - - - updateFilter('memory', newValue)} - onMouseDown={() => handleSliderDragStart('memory')} - onMouseUp={handleSliderDragEnd} - aria-labelledby="memory-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} - min={0} - max={100} - size="small" - /> - - - {/* Disk Filter */} - - - - Disk Usage - - - {formatPercentage(filters.disk)}+ - - - updateFilter('disk', newValue)} - onMouseDown={() => handleSliderDragStart('disk')} - onMouseUp={handleSliderDragEnd} - aria-labelledby="disk-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatPercentage} - min={0} - max={100} - size="small" - /> - - - {/* Network Download Filter */} - - - - Download Rate - - - {formatNetworkRateForFilter(filters.download)}+ - - - updateFilter('download', newValue)} - onMouseDown={() => handleSliderDragStart('download')} - onMouseUp={handleSliderDragEnd} - aria-labelledby="download-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatNetworkRateForFilter} - min={0} - max={100} - size="small" - /> - - - {/* Network Upload Filter */} - - - - Upload Rate - - - {formatNetworkRateForFilter(filters.upload)}+ - - - updateFilter('upload', newValue)} - onMouseDown={() => handleSliderDragStart('upload')} - onMouseUp={handleSliderDragEnd} - aria-labelledby="upload-filter-slider" - valueLabelDisplay="auto" - valueLabelFormat={formatNetworkRateForFilter} - min={0} - max={100} - size="small" - /> - - - - - - - - - + // Formatters + formatPercentage={formatPercentage} + formatNetworkRateForFilter={formatNetworkRateForFilter} + /> {/* Main data table */} - - - - - - -
-
-
-
+ {/* Notification Snackbar */} - - - {snackbarMessage} - - +
); }; diff --git a/frontend/src/components/network/components/NetworkHeader.jsx b/frontend/src/components/network/components/NetworkHeader.jsx new file mode 100644 index 000000000..6f659ec6d --- /dev/null +++ b/frontend/src/components/network/components/NetworkHeader.jsx @@ -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 ( + + + Systems + + + {/* Column visibility button */} + + col.visible).length} + color="primary" + invisible={Object.values(columnVisibility).filter(col => col.visible).length === Object.keys(DEFAULT_COLUMN_CONFIG).length} + > + + + + + {/* Search button */} + + + + + + + {/* System type button */} + + + {guestTypeFilter === 'vm' ? : + guestTypeFilter === 'ct' ? : + } + + + + {/* Visibility button */} + + {showStopped ? : } + + + {/* Resource thresholds button */} + + 0 ? 1 : 0) + + (filters.memory > 0 ? 1 : 0) + + (filters.disk > 0 ? 1 : 0) + + (filters.download > 0 ? 1 : 0) + + (filters.upload > 0 ? 1 : 0) + } + color="primary" + invisible={ + filters.cpu === 0 && + filters.memory === 0 && + filters.disk === 0 && + filters.download === 0 && + filters.upload === 0 + } + > + + + + + + ); +}; + +export default NetworkHeader; \ No newline at end of file diff --git a/frontend/src/components/network/components/NetworkNotification.jsx b/frontend/src/components/network/components/NetworkNotification.jsx new file mode 100644 index 000000000..4cde6a0b0 --- /dev/null +++ b/frontend/src/components/network/components/NetworkNotification.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Snackbar, Alert } from '@mui/material'; + +const NetworkNotification = ({ + snackbarOpen, + snackbarMessage, + snackbarSeverity, + handleSnackbarClose +}) => { + return ( + + + {snackbarMessage} + + + ); +}; + +export default NetworkNotification; \ No newline at end of file diff --git a/frontend/src/components/network/components/NetworkPopovers.jsx b/frontend/src/components/network/components/NetworkPopovers.jsx new file mode 100644 index 000000000..33b6085c1 --- /dev/null +++ b/frontend/src/components/network/components/NetworkPopovers.jsx @@ -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 */} + + + + + Search Systems + + + Search for specific systems by name, ID, or other properties. + + + + + { + e.preventDefault(); + if (searchTerm.trim()) { + addSearchTerm(searchTerm.trim()); + setSearchTerm(''); + } + }} + sx={{ display: 'flex', alignItems: 'center' }} + > + + 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 ? ( + setSearchTerm('')} + sx={{ p: 0.5 }} + > + + + ) : null + }} + sx={{ flex: 1 }} + /> + + + {activeSearchTerms.length > 0 && ( + + + Active Search Terms + + + {activeSearchTerms.map((term) => ( + removeSearchTerm(term)} + /> + ))} + + + )} + + + + + + + + + {/* System type popover */} + + + + + System Type + + + Filter systems by their type. + + + + + { + 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 + } + } + }} + > + + + + All Systems + + + + + + VMs Only + + + + + + CTs Only + + + + + + + + {/* Visibility popover */} + + + + + System Visibility + + + Control which systems are visible. + + + + + { + setShowStopped(!showStopped); + handleCloseVisibilityPopover(); + }} + sx={{ + borderRadius: 1.5, + '&:hover': { + backgroundColor: 'action.hover' + } + }}> + + + {showStopped ? ( + + ) : ( + + )} + + {showStopped ? 'Hide Stopped Systems' : 'Show Stopped Systems'} + + + { + setShowStopped(e.target.checked); + handleCloseVisibilityPopover(); + }} + size="small" + /> + + + + + + + {/* Resource thresholds popover */} + + + + + Resource Thresholds + + + Filter systems by resource usage thresholds. + + + + + {/* CPU Filter */} + + + + CPU Usage + + + {formatPercentage(filters.cpu)}+ + + + updateFilter('cpu', newValue)} + onMouseDown={() => handleSliderDragStart('cpu')} + onMouseUp={handleSliderDragEnd} + aria-labelledby="cpu-filter-slider" + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + min={0} + max={100} + size="small" + /> + + + {/* Memory Filter */} + + + + Memory Usage + + + {formatPercentage(filters.memory)}+ + + + updateFilter('memory', newValue)} + onMouseDown={() => handleSliderDragStart('memory')} + onMouseUp={handleSliderDragEnd} + aria-labelledby="memory-filter-slider" + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + min={0} + max={100} + size="small" + /> + + + {/* Disk Filter */} + + + + Disk Usage + + + {formatPercentage(filters.disk)}+ + + + updateFilter('disk', newValue)} + onMouseDown={() => handleSliderDragStart('disk')} + onMouseUp={handleSliderDragEnd} + aria-labelledby="disk-filter-slider" + valueLabelDisplay="auto" + valueLabelFormat={formatPercentage} + min={0} + max={100} + size="small" + /> + + + {/* Network Download Filter */} + + + + Download Rate + + + {formatNetworkRateForFilter(filters.download)}+ + + + updateFilter('download', newValue)} + onMouseDown={() => handleSliderDragStart('download')} + onMouseUp={handleSliderDragEnd} + aria-labelledby="download-filter-slider" + valueLabelDisplay="auto" + valueLabelFormat={formatNetworkRateForFilter} + min={0} + max={100} + size="small" + /> + + + {/* Network Upload Filter */} + + + + Upload Rate + + + {formatNetworkRateForFilter(filters.upload)}+ + + + updateFilter('upload', newValue)} + onMouseDown={() => handleSliderDragStart('upload')} + onMouseUp={handleSliderDragEnd} + aria-labelledby="upload-filter-slider" + valueLabelDisplay="auto" + valueLabelFormat={formatNetworkRateForFilter} + min={0} + max={100} + size="small" + /> + + + + + + + + + + ); +}; + +export default NetworkPopovers; \ No newline at end of file diff --git a/frontend/src/components/network/components/NetworkTable.jsx b/frontend/src/components/network/components/NetworkTable.jsx new file mode 100644 index 000000000..cf10ef391 --- /dev/null +++ b/frontend/src/components/network/components/NetworkTable.jsx @@ -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 ( + + + + + + +
+
+
+
+ ); +}; + +export default NetworkTable; \ No newline at end of file diff --git a/frontend/src/components/network/components/index.js b/frontend/src/components/network/components/index.js new file mode 100644 index 000000000..cca9b8e76 --- /dev/null +++ b/frontend/src/components/network/components/index.js @@ -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'; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/index.js b/frontend/src/components/network/hooks/index.js new file mode 100644 index 000000000..b58225cdb --- /dev/null +++ b/frontend/src/components/network/hooks/index.js @@ -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'; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useActiveFilteredColumns.js b/frontend/src/components/network/hooks/useActiveFilteredColumns.js new file mode 100644 index 000000000..f75b686da --- /dev/null +++ b/frontend/src/components/network/hooks/useActiveFilteredColumns.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useColumnManagement.js b/frontend/src/components/network/hooks/useColumnManagement.js new file mode 100644 index 000000000..c751b80f8 --- /dev/null +++ b/frontend/src/components/network/hooks/useColumnManagement.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useDataProcessing.js b/frontend/src/components/network/hooks/useDataProcessing.js new file mode 100644 index 000000000..3ca60b2cd --- /dev/null +++ b/frontend/src/components/network/hooks/useDataProcessing.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useKeyboardShortcuts.js b/frontend/src/components/network/hooks/useKeyboardShortcuts.js new file mode 100644 index 000000000..fa8c70ca2 --- /dev/null +++ b/frontend/src/components/network/hooks/useKeyboardShortcuts.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useNetworkFilters.js b/frontend/src/components/network/hooks/useNetworkFilters.js new file mode 100644 index 000000000..64a8edae0 --- /dev/null +++ b/frontend/src/components/network/hooks/useNetworkFilters.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useNotifications.js b/frontend/src/components/network/hooks/useNotifications.js new file mode 100644 index 000000000..b571165fc --- /dev/null +++ b/frontend/src/components/network/hooks/useNotifications.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/usePopoverManagement.js b/frontend/src/components/network/hooks/usePopoverManagement.js new file mode 100644 index 000000000..162818ee1 --- /dev/null +++ b/frontend/src/components/network/hooks/usePopoverManagement.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/network/hooks/useSortManagement.js b/frontend/src/components/network/hooks/useSortManagement.js new file mode 100644 index 000000000..d4424c8a7 --- /dev/null +++ b/frontend/src/components/network/hooks/useSortManagement.js @@ -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; \ No newline at end of file