+
+
+ ← Swipe for more →
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
| Name |
Type |
- ID |
- Up |
+ ID |
+ Up |
CPU |
Memory |
Disk |
- Read |
- Write |
- Net In |
- Net Out |
+ Read |
+ Write |
+ Net In |
+ Net Out |
|
@@ -411,7 +565,7 @@
-
+
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}${cellTag}>`;
+ 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}${cellTag}>`;
+ // Add empty cells for remaining columns
+ for (let i = 1; i < colspan; i++) {
+ html += `<${cellTag} class="bg-gray-200 dark:bg-gray-700">${cellTag}>`;
+ }
+ return html;
+ }
+
+ // Desktop: use colspan
+ return `<${cellTag} colspan="${colspan}" class="${baseClasses} bg-gray-200 dark:bg-gray-700 node-header-cell">${text}${cellTag}>`;
}
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 `
- ${text}
- ${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