feat: add mobile screenshots and enhance screenshot tool
- Add mobile viewport support to screenshot script with iPhone 14 Pro Max dimensions - Generate mobile screenshots for dashboard, PBS view, and backups view - Fix chart toggle selector bug (label:has() instead of missing for attribute) - Reduce screenshot wait times for faster generation - Update README with new mobile screenshots section - Refresh all existing desktop screenshots 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
@@ -24,6 +24,22 @@ A lightweight monitoring application for Proxmox VE that displays real-time stat
|
||||
|
||||
</details>
|
||||
|
||||
### 📱 Mobile Screenshots
|
||||
|
||||
<details>
|
||||
<summary><strong>Click to view mobile interface</strong></summary>
|
||||
|
||||
**Mobile Dashboard:**
|
||||

|
||||
|
||||
**Mobile PBS View:**
|
||||

|
||||
|
||||
**Mobile Backups View:**
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
[](https://ko-fi.com/rcourtman)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
|
Before Width: | Height: | Size: 557 KiB After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 245 KiB |
@@ -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();
|
||||
@@ -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';
|
||||
|
||||