mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Update UI components and charts functionality
This commit is contained in:
+173
-19
@@ -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 */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -317,33 +467,37 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container max-h-[70vh] overflow-y-auto overflow-x-auto mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden scrollbar">
|
||||
<table id="main-table" class="w-full table-fixed text-xs sm:text-sm" role="table" aria-label="Virtual machines and containers">
|
||||
<!-- Mobile scroll hint -->
|
||||
<div class="fixed bottom-4 right-4 bg-blue-500 text-white text-[11px] px-3 py-1.5 rounded-full md:hidden animate-pulse shadow-lg z-50 pointer-events-none" id="scroll-hint" style="display: none;">
|
||||
← Swipe for more →
|
||||
</div>
|
||||
<table id="main-table" class="w-full min-w-[800px] table-auto text-xs sm:text-sm" role="table" aria-label="Virtual machines and containers">
|
||||
<colgroup>
|
||||
<col class="name-col" style="width: var(--name-col-width, 150px);">
|
||||
<col class="name-col" style="min-width: 140px; max-width: 250px;">
|
||||
<col class="type-col" style="width: 50px;">
|
||||
<col class="id-col" style="width: 50px;">
|
||||
<col class="uptime-col" style="width: var(--uptime-col-width, 80px);" title="Uptime">
|
||||
<col class="usage-col" style="width: 120px;">
|
||||
<col class="usage-col" style="width: 120px;">
|
||||
<col class="usage-col" style="width: 120px;">
|
||||
<col class="net-disk-col" style="width: 70px;">
|
||||
<col class="net-disk-col" style="width: 70px;">
|
||||
<col class="net-disk-col" style="width: 70px;">
|
||||
<col class="net-disk-col" style="width: 70px;">
|
||||
<col class="uptime-col" style="min-width: 60px; max-width: 100px;">
|
||||
<col class="usage-col" style="min-width: 100px; max-width: 150px;">
|
||||
<col class="usage-col" style="min-width: 100px; max-width: 150px;">
|
||||
<col class="usage-col" style="min-width: 100px; max-width: 150px;">
|
||||
<col class="net-disk-col" style="min-width: 60px; max-width: 100px;">
|
||||
<col class="net-disk-col" style="min-width: 60px; max-width: 100px;">
|
||||
<col class="net-disk-col" style="min-width: 60px; max-width: 100px;">
|
||||
<col class="net-disk-col" style="min-width: 60px; max-width: 100px;">
|
||||
</colgroup>
|
||||
<thead class="sticky top-0 z-10 bg-gray-100 dark:bg-gray-800">
|
||||
<tr class="text-[10px] sm:text-xs font-medium tracking-wider text-left text-gray-600 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
|
||||
<th class="sortable p-1 px-2" data-sort="name" style="width: var(--name-col-width, 150px);">Name</th>
|
||||
<th class="p-1 px-2" style="width: 50px;">Type</th>
|
||||
<th class="sortable p-1 px-2 hidden sm:table-cell" data-sort="id" style="width: 50px;">ID</th>
|
||||
<th class="sortable p-1 px-2 hidden md:table-cell" data-sort="uptime" style="width: var(--uptime-col-width, 80px);" title="Uptime">Up</th>
|
||||
<th class="sortable p-1 px-2" data-sort="id" style="width: 50px;">ID</th>
|
||||
<th class="sortable p-1 px-2" data-sort="uptime" style="width: var(--uptime-col-width, 80px);" title="Uptime">Up</th>
|
||||
<th class="sortable p-1 px-2" data-sort="cpu" style="width: 120px;">CPU</th>
|
||||
<th class="sortable p-1 px-2" data-sort="memory" style="width: 120px;">Memory</th>
|
||||
<th class="sortable p-1 px-2" data-sort="disk" style="width: 120px;">Disk</th>
|
||||
<th class="sortable p-1 px-2 hidden lg:table-cell" data-sort="diskread" style="width: 70px;">Read</th>
|
||||
<th class="sortable p-1 px-2 hidden lg:table-cell" data-sort="diskwrite" style="width: 70px;">Write</th>
|
||||
<th class="sortable p-1 px-2 hidden lg:table-cell" data-sort="netin" style="width: 70px;">Net In</th>
|
||||
<th class="sortable p-1 px-2 hidden lg:table-cell" data-sort="netout" style="width: 70px;">Net Out</th>
|
||||
<th class="sortable p-1 px-2" data-sort="diskread" style="width: 70px;">Read</th>
|
||||
<th class="sortable p-1 px-2" data-sort="diskwrite" style="width: 70px;">Write</th>
|
||||
<th class="sortable p-1 px-2" data-sort="netin" style="width: 70px;">Net In</th>
|
||||
<th class="sortable p-1 px-2" data-sort="netout" style="width: 70px;">Net Out</th>
|
||||
</tr>
|
||||
<tr id="threshold-slider-row" class="hidden mt-3 p-3 border rounded-md bg-gray-50 dark:bg-gray-700/30 border-gray-200 dark:border-gray-600">
|
||||
<th class="py-1 px-2 align-middle"></th>
|
||||
@@ -411,7 +565,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span id="dashboard-status-text" class="text-xs text-gray-500 dark:text-gray-400">Loading dashboard data...</span>
|
||||
|
||||
|
||||
+4
-61
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = '<table id="main-table" class="w-full table-fixed text-xs sm:text-sm" role="table" aria-label="Virtual machines and containers"><tbody></tbody></table>';
|
||||
tableContainer.innerHTML = '<table id="main-table" class="w-full min-w-[800px] table-auto text-xs sm:text-sm" role="table" aria-label="Virtual machines and containers"><tbody></tbody></table>';
|
||||
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 = `
|
||||
<td class="p-1 px-2 whitespace-nowrap truncate" title="${guest.name}">${guest.name}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap overflow-hidden text-ellipsis max-w-0" title="${guest.name}">${guest.name}</td>
|
||||
<td class="p-1 px-2">${typeIcon}</td>
|
||||
<td class="p-1 px-2 hidden sm:table-cell">${guest.id}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap hidden md:table-cell">${uptimeDisplay}</td>
|
||||
<td class="p-1 px-2">${guest.id}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap overflow-hidden text-ellipsis">${uptimeDisplay}</td>
|
||||
<td class="p-1 px-2">${cpuBarHTML}</td>
|
||||
<td class="p-1 px-2">${memoryBarHTML}</td>
|
||||
<td class="p-1 px-2">${diskBarHTML}</td>
|
||||
<td class="p-1 px-2 hidden lg:table-cell">${diskReadCell}</td>
|
||||
<td class="p-1 px-2 hidden lg:table-cell">${diskWriteCell}</td>
|
||||
<td class="p-1 px-2 hidden lg:table-cell">${netInCell}</td>
|
||||
<td class="p-1 px-2 hidden lg:table-cell">${netOutCell}</td>
|
||||
<td class="p-1 px-2">${diskReadCell}</td>
|
||||
<td class="p-1 px-2">${diskWriteCell}</td>
|
||||
<td class="p-1 px-2">${netInCell}</td>
|
||||
<td class="p-1 px-2">${netOutCell}</td>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
+104
-34
@@ -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 = `
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center min-w-0">
|
||||
<span class="h-2 w-2 rounded-full ${statusDotColor} mr-1.5 flex-shrink-0"></span>
|
||||
<h3 class="font-semibold text-xs truncate">${node.node || 'Unknown'}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[10px] text-gray-600 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-medium">CPU</span>
|
||||
<span class="font-bold ${PulseApp.utils.getUsageColor(cpuPercent, 'cpu')}">${cpuPercent.toFixed(0)}%</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-medium">MEM</span>
|
||||
<span class="font-bold ${PulseApp.utils.getUsageColor(memPercent, 'memory')}">${memPercent.toFixed(0)}%</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-medium">DISK</span>
|
||||
<span class="font-bold ${PulseApp.utils.getUsageColor(diskPercent, 'disk')}">${diskPercent.toFixed(0)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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
|
||||
};
|
||||
|
||||
+41
-8
@@ -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 `
|
||||
<div class="relative w-full h-3.5 rounded overflow-hidden ${bgColorClass}">
|
||||
<div class="absolute top-0 left-0 h-full ${progressColorClass}" style="width: ${percentage}%;"></div>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
|
||||
<span class="hidden sm:inline truncate px-1">${text}</span>
|
||||
<span class="inline sm:hidden">${mobileText}</span>
|
||||
<span class="progress-text-full truncate px-1" data-progress-id="${uniqueId}" data-full-text="${text.replace(/"/g, '"')}" data-simple-text="${mobileText.replace(/"/g, '"')}">${text}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user