diff --git a/README.md b/README.md index dfe94ab57..e0fe59572 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,22 @@ A lightweight monitoring application for Proxmox VE that displays real-time stat +### 📱 Mobile Screenshots + +
+Click to view mobile interface + +**Mobile Dashboard:** +![Mobile Dashboard](docs/images/05-mobile-dashboard.png) + +**Mobile PBS View:** +![Mobile PBS View](docs/images/06-mobile-pbs-view.png) + +**Mobile Backups View:** +![Mobile Backups View](docs/images/07-mobile-backups-view.png) + +
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman) ## 🚀 Quick Start diff --git a/docs/images/01-dashboard.png b/docs/images/01-dashboard.png index d7935f88a..fbbd927c0 100644 Binary files a/docs/images/01-dashboard.png and b/docs/images/01-dashboard.png differ diff --git a/docs/images/02-pbs-view.png b/docs/images/02-pbs-view.png index 8d851068a..8fe50b896 100644 Binary files a/docs/images/02-pbs-view.png and b/docs/images/02-pbs-view.png differ diff --git a/docs/images/03-backups-view.png b/docs/images/03-backups-view.png index 1719a8adf..3867a6744 100644 Binary files a/docs/images/03-backups-view.png and b/docs/images/03-backups-view.png differ diff --git a/docs/images/04-line-graph-toggle.png b/docs/images/04-line-graph-toggle.png index 28e9a4d2b..fc9a17302 100644 Binary files a/docs/images/04-line-graph-toggle.png and b/docs/images/04-line-graph-toggle.png differ diff --git a/docs/images/05-mobile-dashboard.png b/docs/images/05-mobile-dashboard.png new file mode 100644 index 000000000..72c28ccc0 Binary files /dev/null and b/docs/images/05-mobile-dashboard.png differ diff --git a/docs/images/06-mobile-pbs-view.png b/docs/images/06-mobile-pbs-view.png new file mode 100644 index 000000000..667c84d29 Binary files /dev/null and b/docs/images/06-mobile-pbs-view.png differ diff --git a/docs/images/07-mobile-backups-view.png b/docs/images/07-mobile-backups-view.png new file mode 100644 index 000000000..8cb08b4e8 Binary files /dev/null and b/docs/images/07-mobile-backups-view.png differ diff --git a/scripts/take-screenshots.js b/scripts/take-screenshots.js index 8d4337c73..90ca8bcb1 100644 --- a/scripts/take-screenshots.js +++ b/scripts/take-screenshots.js @@ -5,7 +5,8 @@ const fs = require('fs'); // --- Configuration --- const BASE_URL = process.env.PULSE_URL || 'http://localhost:7655'; // Allow overriding via env var const OUTPUT_DIR = path.resolve(__dirname, '../docs/images'); -const VIEWPORT = { width: 1440, height: 900 }; // Match common M1 Air scaled resolution (16:10) +const DESKTOP_VIEWPORT = { width: 1440, height: 900 }; // Match common M1 Air scaled resolution (16:10) +const MOBILE_VIEWPORT = { width: 430, height: 932 }; // iPhone 14 Pro Max dimensions const WAIT_OPTIONS = { waitUntil: 'networkidle', timeout: 15000 }; // Increased timeout, networkidle const OVERLAY_SELECTOR = '#loading-overlay'; @@ -83,18 +84,15 @@ const sections = [ const lxcFilterLabel = page.locator('label[for="filter-lxc"]'); await lxcFilterLabel.waitFor({ state: 'visible', timeout: 10000 }); await lxcFilterLabel.click(); - await page.waitForTimeout(500); + await page.waitForTimeout(300); - console.log(' Action: Clicking charts toggle button'); - // Click the charts toggle button - const chartsToggle = page.locator('#toggle-charts-button'); - await chartsToggle.waitFor({ state: 'visible', timeout: 10000 }); - await chartsToggle.click(); + console.log(' Action: Clicking charts toggle label'); + // Click the charts toggle label (checkbox is hidden, label wraps it) + const chartsToggleLabel = page.locator('label:has(#toggle-charts-checkbox)'); + await chartsToggleLabel.waitFor({ state: 'visible', timeout: 10000 }); + await chartsToggleLabel.click(); console.log(' Action: Waiting for charts to appear'); - // Wait for charts to be visible - await page.waitForTimeout(2000); // Allow time for charts to render and data to load - // Wait for the main container to have charts-mode class indicating charts are shown console.log(' Action: Checking for charts mode'); await page.waitForFunction(() => { @@ -102,8 +100,8 @@ const sections = [ return mainContainer && mainContainer.classList.contains('charts-mode'); }, { timeout: 5000 }); - // Additional wait to ensure charts are fully rendered - await page.waitForTimeout(2000); + // Brief wait to ensure charts are fully rendered + await page.waitForTimeout(800); console.log(' Action: Charts are now visible'); // Hover over a chart to show tooltip @@ -123,7 +121,7 @@ const sections = [ await page.mouse.move(hoverX, hoverY); // Wait for tooltip to appear - await page.waitForTimeout(500); + await page.waitForTimeout(300); console.log(' Action: Tooltip should now be visible'); } } catch (e) { @@ -131,17 +129,17 @@ const sections = [ } }, postAction: async (page) => { - console.log(' Action: Clicking charts toggle button again to hide charts'); + console.log(' Action: Clicking charts toggle label again to hide charts'); // Toggle charts off again - const chartsToggle = page.locator('#toggle-charts-button'); - await chartsToggle.click(); - await page.waitForTimeout(500); + const chartsToggleLabel = page.locator('label:has(#toggle-charts-checkbox)'); + await chartsToggleLabel.click(); + await page.waitForTimeout(300); // Reset filter to show all console.log(' Action: Resetting filter to show all'); const allFilterLabel = page.locator('label[for="filter-all"]'); await allFilterLabel.click(); - await page.waitForTimeout(500); + await page.waitForTimeout(300); // Show node summary cards again console.log(' Action: Showing node summary cards'); @@ -150,11 +148,156 @@ const sections = [ } ]; +// Mobile sections - similar to desktop but with mobile-specific adjustments +const mobileSections = [ + // Mobile Dashboard + { name: '05-mobile-dashboard', + fullPage: true, + mobile: true, + action: async (page) => { + console.log(' Action: Waiting for mobile dashboard content to load...'); + await page.locator('#main-table tbody tr:not(:has(td:text("Loading data...")))').first().waitFor({ state: 'visible', timeout: 30000 }); + console.log(' Action: Mobile dashboard content loaded.'); + } + }, + + // Mobile PBS View + { name: '06-mobile-pbs-view', + mobile: true, + action: async (page) => { + console.log(' Action: Clicking PBS tab (mobile)'); + await page.locator('[data-tab="pbs"]').click(); + console.log(' Action: Waiting for PBS container to be visible (mobile)'); + await page.locator('#pbs #pbs-instances-container').waitFor({ state: 'visible', timeout: 10000 }); + + try { + await page.locator('#pbs .pbs-status-table tbody tr, #pbs .pbs-datastore-table tbody tr').first().waitFor({ state: 'visible', timeout: 15000 }); + console.log(' Action: PBS data loaded (mobile)'); + } catch (e) { + console.log(' Warning: PBS data may not be fully loaded (mobile)'); + } + + await page.waitForTimeout(500); + } + }, + + // Mobile Backups View + { name: '07-mobile-backups-view', + screenshotTarget: '#backups', + mobile: true, + action: async (page) => { + console.log(' Action: Clicking Backups tab (mobile)'); + await page.locator('[data-tab="backups"]').click(); + console.log(' Action: Waiting for backups table row to be visible (mobile)'); + await page.locator('#backups-overview-tbody tr').first().waitFor({ state: 'visible', timeout: 15000 }); + console.log(' Action: Backups table row visible (mobile)'); + } + } +]; + +async function captureScreenshotsForViewport(browser, sectionsToCapture, viewport, viewportName) { + console.log(`\n--- Starting ${viewportName} captures ---`); + + const context = await browser.newContext({ + viewport: viewport, + ignoreHTTPSErrors: true, + deviceScaleFactor: 2, + isMobile: viewportName === 'mobile' // Set isMobile for mobile context + }); + const page = await context.newPage(); + + console.log(`Navigating to base URL for ${viewportName}...`); + await page.goto(BASE_URL, WAIT_OPTIONS); + + // Wait for the loading overlay to disappear + console.log(`Waiting for overlay (${OVERLAY_SELECTOR}) to disappear...`); + await page.locator(OVERLAY_SELECTOR).waitFor({ state: 'hidden', timeout: 20000 }); + console.log('Overlay hidden.'); + + // Ensure Dark Mode + console.log('Ensuring dark mode is active...'); + const isDarkMode = await page.evaluate(() => document.documentElement.classList.contains('dark')); + if (!isDarkMode) { + console.log(' Dark mode not active, clicking theme toggle button...'); + const themeButton = page.locator('#theme-toggle-button'); + await themeButton.waitFor({ state: 'visible', timeout: 5000 }); + await themeButton.click(); + await page.waitForTimeout(300); + console.log(' Dark mode toggled.'); + } else { + console.log(' Dark mode already active.'); + } + + console.log(`Starting ${viewportName} section captures.`); + + for (const section of sectionsToCapture) { + const screenshotPath = path.join(OUTPUT_DIR, `${section.name}.png`); + console.log(`Capturing section: ${section.name}...`); + + try { + // Perform specific actions if needed + if (section.action) { + console.log(` Performing action for ${section.name}...`); + await section.action(page); + await page.waitForLoadState('networkidle', { timeout: 10000 }); + console.log(' Action completed and network idle.'); + } else { + // Ensure we are on the main tab + const mainTabIsActive = await page.locator('[data-tab="main"].active').isVisible(); + if (!mainTabIsActive) { + await page.locator('[data-tab="main"]').click(); + await page.waitForLoadState('networkidle', { timeout: 5000 }); + } + } + + // Take the screenshot + let elementToCapture; + let captureFullPage = section.fullPage || false; + + console.log(` Locating screenshot target: ${section.screenshotTarget || 'page (fullPage: '+captureFullPage+')'}`); + if (section.screenshotTarget) { + elementToCapture = page.locator(section.screenshotTarget).first(); + console.log(' Waiting for screenshot target element to be visible...'); + await elementToCapture.waitFor({ state: 'visible', timeout: 10000 }); + console.log(' Target element visible.'); + captureFullPage = false; + } else { + elementToCapture = page; + } + + console.log(` Saving screenshot to: ${screenshotPath}`); + if (elementToCapture === page) { + console.log(` Capturing ${captureFullPage ? 'full page' : 'viewport'}`); + await page.screenshot({ path: screenshotPath, fullPage: captureFullPage }); + } else { + console.log(' Capturing specific element'); + await elementToCapture.screenshot({ path: screenshotPath }); + } + + console.log(` Successfully captured ${section.name}`); + + // Perform post-action if defined + if (section.postAction) { + console.log(` Performing post-action for ${section.name}...`); + await section.postAction(page); + await page.waitForLoadState('networkidle', { timeout: 5000 }); + console.log(' Post-action completed and network idle.'); + } + + } catch (error) { + console.error(` Failed to capture section ${section.name}: ${error.message}`); + } + } + + await context.close(); + console.log(`${viewportName} captures completed.`); +} + async function takeScreenshots() { console.log(`Starting screenshot capture for ${BASE_URL}...`); console.log(`Outputting to: ${OUTPUT_DIR}`); - // --- Clean up existing PNG files --- + // Clean up existing PNG files if (fs.existsSync(OUTPUT_DIR)) { console.log(`Cleaning up existing *.png files in ${OUTPUT_DIR}...`); const files = fs.readdirSync(OUTPUT_DIR); @@ -164,7 +307,6 @@ async function takeScreenshots() { const filePath = path.join(OUTPUT_DIR, file); try { fs.unlinkSync(filePath); - // console.log(` Deleted: ${file}`); // Optional: more verbose logging deletedCount++; } catch (err) { console.error(` Error deleting file ${file}: ${err.message}`); @@ -175,9 +317,8 @@ async function takeScreenshots() { } else { console.log('Output directory does not exist, no cleanup needed.'); } - // --- End Cleanup --- - // Ensure output directory exists (might have been deleted if empty or just created) + // Ensure output directory exists if (!fs.existsSync(OUTPUT_DIR)) { console.log(`Creating directory: ${OUTPUT_DIR}`); fs.mkdirSync(OUTPUT_DIR, { recursive: true }); @@ -185,115 +326,25 @@ async function takeScreenshots() { let browser; try { - browser = await chromium.launch(); // Or firefox, webkit - const context = await browser.newContext({ - viewport: VIEWPORT, - ignoreHTTPSErrors: true, // Helpful if using self-signed certs for Pulse dev - deviceScaleFactor: 2 // Render at 2x detail for higher quality screenshots - }); - const page = await context.newPage(); + browser = await chromium.launch(); - console.log('Navigating to base URL and waiting for initial load...'); - await page.goto(BASE_URL, WAIT_OPTIONS); + // Capture desktop screenshots + await captureScreenshotsForViewport(browser, sections, DESKTOP_VIEWPORT, 'desktop'); - // Wait for the loading overlay to disappear before starting captures - console.log(`Waiting for overlay (${OVERLAY_SELECTOR}) to disappear...`); - await page.locator(OVERLAY_SELECTOR).waitFor({ state: 'hidden', timeout: 20000 }); // Increased timeout - console.log('Overlay hidden.'); - - // --- Ensure Dark Mode --- - console.log('Ensuring dark mode is active...'); - const isDarkMode = await page.evaluate(() => document.documentElement.classList.contains('dark')); - if (!isDarkMode) { - console.log(' Dark mode not active, clicking theme toggle button...'); - const themeButton = page.locator('#theme-toggle-button'); - await themeButton.waitFor({ state: 'visible', timeout: 5000 }); - await themeButton.click(); - await page.waitForTimeout(500); // Wait for theme transition - console.log(' Dark mode toggled.'); - } else { - console.log(' Dark mode already active.'); - } - // --- End Ensure Dark Mode --- - - console.log('Starting section captures.'); - - for (const section of sections) { - const screenshotPath = path.join(OUTPUT_DIR, `${section.name}.png`); - console.log(`Capturing section: ${section.name}...`); - - try { - // Perform specific actions if needed (clicks, etc.) - if (section.action) { - console.log(` Performing action for ${section.name}...`); - await section.action(page); - // Wait after action for UI to settle - using networkidle should be sufficient - await page.waitForLoadState('networkidle', { timeout: 10000 }); - console.log(' Action completed and network idle.'); - } else { - // Ensure we are on the main tab for sections without specific actions - // (Applies mainly if dashboard wasn't the very first step) - const mainTabIsActive = await page.locator('[data-tab="main"].active').isVisible(); - if (!mainTabIsActive) { - await page.locator('[data-tab="main"]').click(); - await page.waitForLoadState('networkidle', { timeout: 5000 }); - } - } - - // Take the screenshot - let elementToCapture; - let captureFullPage = section.fullPage || false; // Get flag, default false - - console.log(` Locating screenshot target: ${section.screenshotTarget || 'page (fullPage: '+captureFullPage+')'}`); - if (section.screenshotTarget) { // Check if a specific target is defined - elementToCapture = page.locator(section.screenshotTarget).first(); - console.log(' Waiting for screenshot target element to be visible...'); - await elementToCapture.waitFor({ state: 'visible', timeout: 10000 }); - console.log(' Target element visible.'); - captureFullPage = false; // Never capture full page when targeting a specific element - } else { - elementToCapture = page; // Use the page itself for viewport/fullpage screenshots - } - - console.log(` Saving screenshot to: ${screenshotPath}`); - if (elementToCapture === page) { - // Capture viewport or full page based on the flag - console.log(` Capturing ${captureFullPage ? 'full page' : 'viewport'}`); - await page.screenshot({ path: screenshotPath, fullPage: captureFullPage }); - } else { - // Capture specific element - console.log(' Capturing specific element'); - await elementToCapture.screenshot({ path: screenshotPath }); - } - - console.log(` Successfully captured ${section.name}`); - - // Perform post-action if defined (e.g., to restore UI state) - if (section.postAction) { - console.log(` Performing post-action for ${section.name}...`); - await section.postAction(page); - // Optionally wait for UI to settle after post-action - await page.waitForLoadState('networkidle', { timeout: 5000 }); - console.log(' Post-action completed and network idle.'); - } - - } catch (error) { - console.error(` Failed to capture section ${section.name}: ${error.message}`); - // Optionally, decide if you want to continue or stop on error - } - } + // Capture mobile screenshots + await captureScreenshotsForViewport(browser, mobileSections, MOBILE_VIEWPORT, 'mobile'); } catch (error) { console.error(`Error during screenshot process: ${error}`); - process.exitCode = 1; // Indicate failure + process.exitCode = 1; } finally { if (browser) { await browser.close(); - console.log('Browser closed.'); + console.log('\nBrowser closed.'); } } - console.log('Screenshot capture finished.'); + console.log('\nScreenshot capture finished.'); } takeScreenshots(); \ No newline at end of file diff --git a/src/public/js/ui/pbs.js b/src/public/js/ui/pbs.js index 5dc80ae95..17cd5c0ca 100644 --- a/src/public/js/ui/pbs.js +++ b/src/public/js/ui/pbs.js @@ -1569,7 +1569,12 @@ PulseApp.ui.pbs = (() => { // CPU and Memory with progress bars if (nodeStatus.cpu !== null && nodeStatus.cpu !== undefined) { const cpuPercent = nodeStatus.cpu * 100; - const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent, 'cpu'); + const cpuColor = PulseApp.utils.getUsageColor(cpuPercent, 'cpu'); + const cpuColorClass = { + 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' + }[cpuColor] || 'bg-gray-500/60 dark:bg-gray-500/50'; const cpuDiv = document.createElement('div'); cpuDiv.className = 'space-y-1'; @@ -1589,7 +1594,12 @@ PulseApp.ui.pbs = (() => { const memUsed = nodeStatus.memory.used; const memTotal = nodeStatus.memory.total; const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0; - const memColorClass = PulseApp.utils.getUsageColor(memPercent, 'memory'); + const memColor = PulseApp.utils.getUsageColor(memPercent, 'memory'); + const memColorClass = { + 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' + }[memColor] || 'bg-gray-500/60 dark:bg-gray-500/50'; const memDiv = document.createElement('div'); memDiv.className = 'space-y-1';