diff --git a/src/public/index.html b/src/public/index.html index ecf346c63..99f61029e 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -157,11 +157,161 @@ /* Enhanced metric cell layouts */ .usage-col { - min-width: 120px; + min-width: 100px; } .net-disk-col { - min-width: 70px; + min-width: 60px; + } + + /* Prevent text wrapping in all table cells */ + #main-table td, + #main-table th { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Special handling for name column to enable ellipsis */ + #main-table td:first-child { + max-width: 250px; + } + + /* Mobile table scroll styling */ + @media (max-width: 768px) { + .table-container { + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + } + .table-container::-webkit-scrollbar { + height: 10px; + } + .table-container::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 5px; + } + .table-container::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.3); + border-radius: 5px; + } + .dark .table-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + } + .dark .table-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + } + + /* ALL first cells are sticky on mobile */ + #main-table td:first-child, + #main-table th:first-child { + position: sticky !important; + left: -2px; /* Shift left to cover gap */ + z-index: 1; + padding-left: 10px; /* Add extra padding to compensate */ + } + + /* Guest row first cells */ + #main-table tbody tr:not(.node-header) td:first-child { + background: white; + background-clip: padding-box; + box-shadow: 2px 0 2px -1px rgba(0, 0, 0, 0.05); + } + .dark #main-table tbody tr:not(.node-header) td:first-child { + background: rgb(31 41 55); + background-clip: padding-box; + box-shadow: 2px 0 2px -1px rgba(0, 0, 0, 0.2); + } + #main-table tbody tr:not(.node-header):hover td:first-child { + background: rgb(249 250 251); + } + .dark #main-table tbody tr:not(.node-header):hover td:first-child { + background: rgb(55 65 81); + } + + /* Table header first cell */ + #main-table thead th:first-child { + background: rgb(243 244 246); + background-clip: padding-box; + z-index: 11; + box-shadow: 2px 0 2px -1px rgba(0, 0, 0, 0.05); + } + .dark #main-table thead th:first-child { + background: rgb(55 65 81); + background-clip: padding-box; + box-shadow: 2px 0 2px -1px rgba(0, 0, 0, 0.2); + } + + /* Node header first cell styling */ + #main-table tr.node-header td:first-child { + z-index: 2; + background: rgb(229 231 235) !important; + background-clip: padding-box; + box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1); + } + .dark #main-table tr.node-header td:first-child { + background: rgb(75 85 99) !important; + background-clip: padding-box; + box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.3); + } + + /* Ensure full coverage on sticky cells */ + #main-table td:first-child, + #main-table th:first-child { + min-width: 140px; + position: relative; + } + + /* Solid edge on sticky columns to prevent bleed-through */ + #main-table td:first-child::before, + #main-table th:first-child::before { + content: ''; + position: absolute; + top: -1px; + left: -20px; /* Extended further left */ + right: -1px; + bottom: -1px; + background: inherit; + z-index: -1; + } + + /* Additional left coverage with after element */ + #main-table td:first-child::after, + #main-table th:first-child::after { + content: ''; + position: absolute; + top: 0; + left: -5px; + bottom: 0; + width: 5px; + background: inherit; + z-index: 1; + } + + /* Additional left edge coverage */ + #main-table td:first-child, + #main-table th:first-child { + border-left: 2px solid; + } + #main-table tbody tr:not(.node-header) td:first-child { + border-left-color: white; + } + .dark #main-table tbody tr:not(.node-header) td:first-child { + border-left-color: rgb(31 41 55); + } + #main-table tr.node-header td:first-child { + border-left-color: rgb(229 231 235); + } + .dark #main-table tr.node-header td:first-child { + border-left-color: rgb(75 85 99); + } + #main-table thead th:first-child { + border-left-color: rgb(243 244 246); + } + .dark #main-table thead th:first-child { + border-left-color: rgb(55 65 81); + } + + /* Remove the old node header mobile handling since we're using individual cells now */ } @@ -317,33 +467,37 @@
- + + +
- + - - - - - - - - + + + + + + + + - - + + - - - - + + + + @@ -411,7 +565,7 @@
Name TypeIDUp CPU Memory DiskReadWriteNet InNet Out
-
+ Loading dashboard data... diff --git a/src/public/js/charts.js b/src/public/js/charts.js index ed3b99602..ce0a9c53d 100644 --- a/src/public/js/charts.js +++ b/src/public/js/charts.js @@ -327,67 +327,8 @@ PulseApp.charts = (() => { } }); - // Touch events (mobile) - improved to prevent browser intervention - overlay.addEventListener('touchstart', (event) => { - // Only prevent default if the event is cancelable and we're actually interacting with the chart - if (event.cancelable && event.touches.length === 1) { - event.preventDefault(); // Prevent scrolling only when safe to do so - } - - const touch = event.touches[0]; - // Create a synthetic event object for touch - const syntheticEvent = { - clientX: touch.clientX, - clientY: touch.clientY, - target: event.target - }; - - // Change chart line to white on touch - const path = svg.querySelector('.chart-line'); - if (path) { - path.setAttribute('data-original-color', path.getAttribute('stroke')); - // Use black for light mode, white for dark mode (same as mouse hover) - const isDarkMode = document.documentElement.classList.contains('dark'); - const hoverColor = isDarkMode ? '#ffffff' : '#000000'; - path.setAttribute('stroke', hoverColor); - } - - showTooltipForPosition(syntheticEvent, touch.clientX, touch.clientY); - }, { passive: false }); // Allow preventDefault when needed - - overlay.addEventListener('touchmove', (event) => { - // Only prevent default if the event is cancelable and we have a single touch - if (event.cancelable && event.touches.length === 1) { - event.preventDefault(); // Prevent scrolling only when safe to do so - } - - const touch = event.touches[0]; - // Create a synthetic event object for touch - const syntheticEvent = { - clientX: touch.clientX, - clientY: touch.clientY, - target: event.target - }; - showTooltipForPosition(syntheticEvent, touch.clientX, touch.clientY); - }, { passive: false }); // Allow preventDefault when needed - - overlay.addEventListener('touchend', () => { - // Restore original color - const path = svg.querySelector('.chart-line'); - if (path) { - const originalColor = path.getAttribute('data-original-color'); - if (originalColor) { - path.setAttribute('stroke', originalColor); - } - } - - // Keep tooltip visible for a moment on mobile, then hide - setTimeout(() => { - if (PulseApp.tooltips) { - PulseApp.tooltips.hideTooltip(); - } - }, 2000); // Hide after 2 seconds - }); + // Touch events disabled for now - they interfere with scrolling + // Charts will still work with mouse events on devices that support them svg.appendChild(overlay); @@ -395,6 +336,8 @@ PulseApp.charts = (() => { overlay._chartData = chartData.slice(); // Create a copy to avoid reference issues overlay._metric = metric; overlay._config = config; + + // Remove the touch indicator - charts are discoverable enough without it } function updateChartPath(svg, chartData, config, metric, isNewChart = false, color) { diff --git a/src/public/js/ui/common.js b/src/public/js/ui/common.js index 2e8f757fc..8607e7b4a 100644 --- a/src/public/js/ui/common.js +++ b/src/public/js/ui/common.js @@ -310,8 +310,20 @@ PulseApp.ui.common = (() => { } function generateNodeGroupHeaderCellHTML(text, colspan, cellTag = 'td') { - const cellClasses = 'py-0.5 px-2 bg-gray-200 dark:bg-gray-700 subtle-stripes-light dark:subtle-stripes-dark text-left font-medium text-xs sm:text-sm text-gray-700 dark:text-gray-300'; - return `<${cellTag} colspan="${colspan}" class="${cellClasses}">${text}`; + const baseClasses = 'py-0.5 px-2 text-left font-medium text-xs sm:text-sm text-gray-700 dark:text-gray-300'; + + // On mobile, create individual cells so first one can be sticky + if (window.innerWidth < 768) { + let html = `<${cellTag} class="${baseClasses} bg-gray-200 dark:bg-gray-700">${text}`; + // Add empty cells for remaining columns + for (let i = 1; i < colspan; i++) { + html += `<${cellTag} class="bg-gray-200 dark:bg-gray-700">`; + } + return html; + } + + // Desktop: use colspan + return `<${cellTag} colspan="${colspan}" class="${baseClasses} bg-gray-200 dark:bg-gray-700 node-header-cell">${text}`; } return { diff --git a/src/public/js/ui/dashboard.js b/src/public/js/ui/dashboard.js index d42c2cdf7..520f02acd 100644 --- a/src/public/js/ui/dashboard.js +++ b/src/public/js/ui/dashboard.js @@ -26,6 +26,36 @@ PulseApp.ui.dashboard = (() => { let virtualScroller = null; const VIRTUAL_SCROLL_THRESHOLD = 100; // Use virtual scrolling for >100 items + function _initMobileScrollIndicators() { + const tableContainer = document.querySelector('.table-container'); + const scrollHint = document.getElementById('scroll-hint'); + + if (!tableContainer || !scrollHint) return; + + let scrollHintTimer; + + // Hide scroll hint after 5 seconds or on first scroll + const hideScrollHint = () => { + if (scrollHint) { + scrollHint.style.display = 'none'; + } + }; + + scrollHintTimer = setTimeout(hideScrollHint, 5000); + + // Handle scroll events + tableContainer.addEventListener('scroll', () => { + hideScrollHint(); + clearTimeout(scrollHintTimer); + }, { passive: true }); + + // Also hide on table container click/touch + tableContainer.addEventListener('touchstart', () => { + hideScrollHint(); + clearTimeout(scrollHintTimer); + }, { passive: true }); + } + function init() { searchInput = document.getElementById('dashboard-search'); tableBodyEl = document.querySelector('#main-table tbody'); @@ -41,6 +71,14 @@ PulseApp.ui.dashboard = (() => { if (chartsToggleButton) { chartsToggleButton.addEventListener('click', toggleChartsMode); } + + // Initialize mobile scroll indicators + if (window.innerWidth < 768) { + _initMobileScrollIndicators(); + } + + // Add resize listener for progress bar text updates + window.addEventListener('resize', PulseApp.utils.updateProgressBarTextsDebounced); document.addEventListener('keydown', (event) => { // Handle Escape for resetting filters @@ -500,14 +538,14 @@ PulseApp.ui.dashboard = (() => { nameCell.title = guest.name; } - // Ensure ID cell (2) has responsive classes + // Ensure ID cell (2) has proper classes if (cells[2]) { - cells[2].className = 'p-1 px-2 hidden sm:table-cell'; + cells[2].className = 'p-1 px-2'; } - // Ensure uptime cell (3) has responsive classes + // Ensure uptime cell (3) has proper classes if (cells[3]) { - cells[3].className = 'p-1 px-2 whitespace-nowrap hidden md:table-cell'; + cells[3].className = 'p-1 px-2 whitespace-nowrap overflow-hidden text-ellipsis'; } // Update uptime (cell 3) @@ -546,10 +584,10 @@ PulseApp.ui.dashboard = (() => { diskCell.innerHTML = newDiskHTML; } - // Ensure I/O cells (7-10) have responsive classes + // Ensure I/O cells (7-10) have proper classes [7, 8, 9, 10].forEach(index => { if (cells[index]) { - cells[index].className = 'p-1 px-2 hidden lg:table-cell'; + cells[index].className = 'p-1 px-2'; } }); @@ -678,7 +716,7 @@ PulseApp.ui.dashboard = (() => { const tableContainer = document.querySelector('.table-container'); if (tableContainer) { tableContainer.style.height = ''; - tableContainer.innerHTML = '
'; + tableContainer.innerHTML = '
'; tableBodyEl = document.querySelector('#main-table tbody'); } } @@ -776,6 +814,11 @@ PulseApp.ui.dashboard = (() => { PulseApp.charts.updateAllCharts(); }); } + + // Update progress bar texts based on available width + requestAnimationFrame(() => { + PulseApp.utils.updateProgressBarTexts(); + }); } function _createCpuBarHtml(guest) { @@ -900,17 +943,17 @@ PulseApp.ui.dashboard = (() => { } row.innerHTML = ` - ${guest.name} + ${guest.name} ${typeIcon} - ${guest.id} - ${uptimeDisplay} + ${guest.id} + ${uptimeDisplay} ${cpuBarHTML} ${memoryBarHTML} ${diskBarHTML} - ${diskReadCell} - ${diskWriteCell} - ${netInCell} - ${netOutCell} + ${diskReadCell} + ${diskWriteCell} + ${netInCell} + ${netOutCell} `; return row; } diff --git a/src/public/js/ui/nodes.js b/src/public/js/ui/nodes.js index 8b00eb6fd..f4d6d4743 100644 --- a/src/public/js/ui/nodes.js +++ b/src/public/js/ui/nodes.js @@ -1,6 +1,7 @@ PulseApp.ui = PulseApp.ui || {}; PulseApp.ui.nodes = (() => { + let currentNodesData = null; // Store current nodes data for resize handling function _createNodeCpuBarHtml(node) { const cpuPercent = node.cpu ? (node.cpu * 100) : 0; @@ -131,6 +132,11 @@ PulseApp.ui.nodes = (() => { return; } + // Store nodes data for resize handling + if (nodes) { + currentNodesData = nodes; + } + // Show loading skeletons if no data yet if (!nodes || nodes.length === 0) { if (PulseApp.ui.loadingSkeletons) { @@ -144,44 +150,93 @@ PulseApp.ui.nodes = (() => { container.innerHTML = ''; // Clear previous content const numNodes = nodes.length; - - // Helper function to determine optimal columns to avoid a single orphan - function calculateOptimalColumns(numItems, defaultCols) { - if (numItems <= 0) return defaultCols; // No items, use default or let it be empty - if (defaultCols <= 1) return 1; // Cannot reduce further, or already 1 - // If numItems is less than or equal to defaultCols, use numItems as the column count. - if (numItems <= defaultCols) return numItems; - - // Now numItems > defaultCols (multiple rows expected) - // Avoid a single orphan, but don't reduce to 1 column if defaultCols is 2. - if (numItems % defaultCols === 1) { - if (defaultCols === 2) { - // If default is 2 cols (sm), and we have an odd number of items (e.g., 3, 5), - // use 2 columns to avoid stacking. (Results in 2 side-by-side, then 1 or more). - return defaultCols; - } - // For other defaultCols (>=3), reducing by 1 to avoid an orphan is fine. - return Math.max(1, defaultCols - 1); // Ensure at least 1 column - } - return defaultCols; - } - - const smCols = calculateOptimalColumns(numNodes, 2); - const mdCols = calculateOptimalColumns(numNodes, 3); - const lgCols = calculateOptimalColumns(numNodes, 4); - const xlCols = calculateOptimalColumns(numNodes, 4); // Based on previous preference - - const gridDiv = document.createElement('div'); - gridDiv.className = `grid grid-cols-1 sm:grid-cols-${smCols} md:grid-cols-${mdCols} lg:grid-cols-${lgCols} xl:grid-cols-${xlCols} gap-3`; + const isMobile = window.innerWidth < 640; // sm breakpoint // Sort nodes by name for consistent order in summary cards const sortedNodes = [...nodes].sort((a, b) => (a.node || '').localeCompare(b.node || '')); - sortedNodes.forEach(node => { - const cardElement = createNodeSummaryCard(node); - gridDiv.appendChild(cardElement); - }); - container.appendChild(gridDiv); + if (isMobile) { + // Stack cards vertically on mobile with condensed layout + const stackDiv = document.createElement('div'); + stackDiv.className = 'flex flex-col gap-2'; + + sortedNodes.forEach(node => { + const cardElement = createCondensedNodeCard(node); + stackDiv.appendChild(cardElement); + }); + container.appendChild(stackDiv); + + } else { + // Use grid layout for desktop + // Helper function to determine optimal columns to avoid a single orphan + function calculateOptimalColumns(numItems, defaultCols) { + if (numItems <= 0) return defaultCols; + if (defaultCols <= 1) return 1; + if (numItems <= defaultCols) return numItems; + + if (numItems % defaultCols === 1) { + if (defaultCols === 2) { + return defaultCols; + } + return Math.max(1, defaultCols - 1); + } + return defaultCols; + } + + const smCols = calculateOptimalColumns(numNodes, 2); + const mdCols = calculateOptimalColumns(numNodes, 3); + const lgCols = calculateOptimalColumns(numNodes, 4); + const xlCols = calculateOptimalColumns(numNodes, 4); + + const gridDiv = document.createElement('div'); + gridDiv.className = `grid grid-cols-1 sm:grid-cols-${smCols} md:grid-cols-${mdCols} lg:grid-cols-${lgCols} xl:grid-cols-${xlCols} gap-3`; + + sortedNodes.forEach(node => { + const cardElement = createNodeSummaryCard(node); + gridDiv.appendChild(cardElement); + }); + container.appendChild(gridDiv); + } + } + + function createCondensedNodeCard(node) { + const isOnline = node && node.uptime > 0; + const statusDotColor = isOnline ? 'text-green-500' : 'text-red-500'; + + const cpuPercent = node.cpu ? (node.cpu * 100) : 0; + const memUsed = node.mem || 0; + const memTotal = node.maxmem || 0; + const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0; + const diskUsed = node.disk || 0; + const diskTotal = node.maxdisk || 0; + const diskPercent = (diskUsed && diskTotal > 0) ? (diskUsed / diskTotal * 100) : 0; + + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-gray-800 shadow-sm rounded-lg p-2 border border-gray-200 dark:border-gray-700'; + + card.innerHTML = ` +
+
+ +

${node.node || 'Unknown'}

+
+
+ + CPU + ${cpuPercent.toFixed(0)}% + + + MEM + ${memPercent.toFixed(0)}% + + + DISK + ${diskPercent.toFixed(0)}% + +
+
+ `; + return card; } function updateNodesTable(nodes) { @@ -237,7 +292,22 @@ PulseApp.ui.nodes = (() => { } } + function init() { + // Add resize listener for responsive behavior + let resizeTimeout; + window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + // Re-render cards if we have data + if (currentNodesData) { + updateNodeSummaryCards(currentNodesData); + } + }, 250); // Debounce resize events + }); + } + return { + init, updateNodesTable, updateNodeSummaryCards }; diff --git a/src/public/js/utils.js b/src/public/js/utils.js index 9608e7add..be422752f 100644 --- a/src/public/js/utils.js +++ b/src/public/js/utils.js @@ -39,23 +39,25 @@ PulseApp.utils = (() => { function createProgressTextBarHTML(percentage, text, color, simpleText = null) { // Always use a neutral background regardless of the progress color - const bgColorClass = 'bg-gray-200 dark:bg-gray-700'; + const bgColorClass = 'bg-gray-200 dark:bg-gray-600'; const progressColorClass = { - red: 'bg-red-500/50 dark:bg-red-500/50', - yellow: 'bg-yellow-500/50 dark:bg-yellow-500/50', - green: 'bg-green-500/50 dark:bg-green-500/50' - }[color] || 'bg-gray-500/50'; // Fallback progress color with opacity + red: 'bg-red-500/60 dark:bg-red-500/50', + yellow: 'bg-yellow-500/60 dark:bg-yellow-500/50', + green: 'bg-green-500/60 dark:bg-green-500/50' + }[color] || 'bg-gray-500/60 dark:bg-gray-500/50'; // Fallback progress color with opacity // Use simpleText for narrow screens if provided, otherwise just show percentage const mobileText = simpleText || `${percentage}%`; + + // Generate a unique ID for this progress bar to handle dynamic text switching + const uniqueId = `pb-${Math.random().toString(36).substr(2, 9)}`; return `
- - ${mobileText} + ${text}
`; @@ -254,6 +256,35 @@ PulseApp.utils = (() => { }); } + // Function to check and update progress bar text based on available width + function updateProgressBarTexts() { + const progressTexts = document.querySelectorAll('.progress-text-full'); + + progressTexts.forEach(span => { + const fullText = span.getAttribute('data-full-text'); + const simpleText = span.getAttribute('data-simple-text'); + + if (!fullText || !simpleText) return; + + // Check if the current text is overflowing + const parent = span.parentElement; + if (parent) { + // Temporarily set to full text to measure + span.textContent = fullText; + + // Check if text is truncated (scrollWidth > clientWidth) + if (span.scrollWidth > parent.clientWidth - 8) { // 8px for padding + span.textContent = simpleText; + } else { + span.textContent = fullText; + } + } + }); + } + + // Debounced version for resize events + const updateProgressBarTextsDebounced = debounce(updateProgressBarTexts, 100); + // Return the public API for this module return { sanitizeForId: (str) => str.replace(/[^a-zA-Z0-9-]/g, '-'), @@ -270,6 +301,8 @@ PulseApp.utils = (() => { getReadableThresholdCriteria, sortData, renderTableBody, - debounce + debounce, + updateProgressBarTexts, + updateProgressBarTextsDebounced }; })(); \ No newline at end of file