feat: Add automated screenshot generation script | Implements Playwright script (scripts/take-screenshots.js) to capture UI views. Adds npm run screenshot command. Includes playwright as a dev dependency. Updates README.md to use generated screenshots. (#73)

This commit is contained in:
rcourtman
2025-05-02 10:35:23 +01:00
committed by GitHub
parent a9cfed5c9c
commit 800af62d06
10 changed files with 305 additions and 164 deletions
+21 -163
View File
@@ -6,7 +6,26 @@
A lightweight monitoring application for Proxmox VE that displays real-time status for VMs and containers via a simple web interface.
![Pulse Dashboard](docs/images/dashboard-screenshot.png)
![Pulse Dashboard](docs/images/01-dashboard.png)
### 📸 Screenshots
<details>
<summary><strong>Click to view other UI tabs</strong></summary>
**Nodes Tab:**
![Nodes View](docs/images/02-node-view.png)
**Main Tab (Filtered for VMs):**
![VM Filtered View](docs/images/03-vm-container-view.png)
**PBS Tab:**
![PBS View](docs/images/04-pbs-view.png)
**Backups Tab:**
![Backups View](docs/images/05-backups-view.png)
</details>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman)
@@ -197,165 +216,4 @@ Using Docker Compose is the recommended way for most users.
2. **Run:** In the project root directory, run:
```bash
docker compose up -d
```
This downloads the `rcourtman/pulse:latest` image and starts the container in the background.
3. **Access:** Open `http://<your-host-ip>:7655` (or the host port mapped in `docker-compose.yml`).
**Stopping:**
```bash
docker compose down
```
*Note: Restart the container (`docker compose down && docker compose up -d`) if you change `.env` after starting.*
<details>
<summary><strong>Alternative: Inline Variables in `docker-compose.yml` (Click to Expand)</strong></summary>
You can define environment variables directly in `docker-compose.yml` instead of using `.env`. **Replace placeholder values** before running `docker compose up -d`.
```yaml
version: '3.8'
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse_monitor
restart: unless-stopped
ports:
- "7655:7655" # Map container port 7655 to host port 7655
environment:
# --- Required Proxmox Connection Details ---
PROXMOX_HOST: "https://your-proxmox-ip-or-hostname:8006"
PROXMOX_TOKEN_ID: "your-user@pam!your-token-name"
PROXMOX_TOKEN_SECRET: "your-api-token-secret-uuid"
# --- Optional Settings ---
PROXMOX_ALLOW_SELF_SIGNED_CERTS: "false"
# PBS_HOST: "https://your-pbs-ip:8007"
# PBS_TOKEN_ID: "your-pbs-user@pbs!token"
# PBS_TOKEN_SECRET: "your-pbs-secret"
# PBS_NODE_NAME: "your-pbs-hostname"
# Optional: Mount a local directory for potential future config needs
# volumes:
# - ./pulse_config:/config
networks:
default:
driver: bridge
```
</details>
### Running with LXC Installation Script
An installation script is available for setting up Pulse inside an **existing** Debian/Ubuntu-based Proxmox VE LXC container.
**Prerequisites:**
- A running Proxmox VE host.
- An existing Debian or Ubuntu LXC container with network access to Proxmox.
- *Tip: Use [Community Scripts](https://community-scripts.github.io/ProxmoxVE/scripts?id=debian) to easily create one: `bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)"`*
**Steps:**
1. **Access LXC Console:** Log in to your LXC container (usually as `root`).
2. **Download and Run Script:**
```bash
# Ensure you are in a suitable directory, like /root or /tmp
curl -sLO https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-pulse.sh
chmod +x install-pulse.sh
./install-pulse.sh
```
3. **Follow Prompts:** The script guides you through:
* Installing dependencies (`git`, `curl`, `nodejs`, `npm`, `sudo`).
* Entering your Proxmox Host URL, API Token ID, Secret, and self-signed cert preference.
* (Optional) Entering PBS connection details if desired.
* Setting up Pulse as a `systemd` service (`pulse-monitor.service`).
* Optionally enabling automatic updates via cron.
4. **Access Pulse:** The script will display the URL (e.g., `http://<LXC-IP-ADDRESS>:7655`).
<details>
<summary><strong>Updating and Managing the LXC Installation (Click to Expand)</strong></summary>
**Updating Pulse:**
Re-run the script from the directory where you downloaded it:
```bash
./install-pulse.sh
```
Or run non-interactively (e.g., for cron):
```bash
./install-pulse.sh --update
```
**Managing the Pulse Service:**
Use standard `systemctl` commands:
* Check Status: `sudo systemctl status pulse-monitor.service`
* Stop Service: `sudo systemctl stop pulse-monitor.service`
* Start Service: `sudo systemctl start pulse-monitor.service`
* View Logs: `sudo journalctl -u pulse-monitor.service -f`
* Enable/Disable on Boot: `sudo systemctl enable/disable pulse-monitor.service`
**Automatic Updates:**
If enabled via the script, a cron job runs `./install-pulse.sh --update` Daily/Weekly/Monthly. Logs are in `/var/log/pulse_update.log`. Manage with `sudo crontab -l -u root` or `sudo crontab -e -u root`.
</details>
### Running the Application (Node.js - Development)
For development purposes or running directly from source, see the **[DEVELOPMENT.md](DEVELOPMENT.md)** guide. This involves cloning the repository, installing dependencies using `npm install` in both the root and `server` directories, and running `npm run dev` or `npm run start`.
## ✨ Features
- Lightweight monitoring for Proxmox VE nodes, VMs, and Containers.
- Real-time status updates via WebSockets.
- Simple, responsive web interface.
- Efficient polling: Stops API polling when no clients are connected.
- Docker support.
- Multi-environment PVE monitoring support.
- Proxmox Backup Server (PBS) monitoring support.
- LXC installation script.
## 💻 System Requirements
- **Node.js:** Version 18.x or later (if building/running from source).
- **NPM:** Compatible version with Node.js.
- **Docker & Docker Compose:** Latest stable versions (if using container deployment).
- **Proxmox VE:** Version 7.x or 8.x recommended.
- **Proxmox Backup Server:** Version 2.x or 3.x recommended (if monitored).
- **Web Browser:** Modern evergreen browser.
## 👋 Contributing
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md).
## 🔒 Privacy
* **No Data Collection:** Pulse does not collect or transmit any telemetry or user data externally.
* **Local Communication:** Operates entirely between your environment and your Proxmox/PBS APIs.
* **Credential Handling:** Credentials are used only for API authentication and are not logged or sent elsewhere.
## 📜 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file.
## ™️ Trademark Notice
Proxmox® and Proxmox VE® are registered trademarks of Proxmox Server Solutions GmbH. This project is not affiliated with or endorsed by Proxmox Server Solutions GmbH.
## ❤️ Support
File issues on the [GitHub repository](https://github.com/rcourtman/Pulse/issues).
If you find Pulse useful, consider supporting its development:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman)
## ❓ Troubleshooting
Common connection issues:
* **Pulse Application Logs:** Check container logs (`docker logs pulse_monitor`) or service logs (`sudo journalctl -u pulse-monitor.service -f`) for errors (401 Unauthorized, 403 Forbidden, connection refused, timeout).
* **`.env` Configuration:** Verify `PROXMOX_HOST`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET`, and `PROXMOX_ALLOW_SELF_SIGNED_CERTS`. For PBS, also check `PBS_HOST`, `PBS_TOKEN_ID`, `PBS_TOKEN_SECRET`, `PBS_ALLOW_SELF_SIGNED_CERTS`, and especially **`PBS_NODE_NAME`**. Ensure no placeholder values remain.
* **Network Connectivity:** Can the machine running Pulse reach the PVE/PBS hostnames/IPs and ports (usually 8006 for PVE, 8007 for PBS)? Check firewalls.
* **API Token Permissions:** Ensure the correct roles (`PVEAuditor` for PVE, `Audit` for PBS) are assigned at the root path (`/`) with `Propagate` enabled in the respective UIs.
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 KiB

+48
View File
@@ -18,6 +18,7 @@
"conventional-changelog-cli": "^5.0.0",
"dotenv": "^16.5.0",
"jest": "^29.7.0",
"playwright": "^1.52.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.4"
}
@@ -4680,6 +4681,53 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+3 -1
View File
@@ -9,7 +9,8 @@
"dev:css": "tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css --watch",
"build:css": "tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css",
"dev": "concurrently \"npm:dev:server\" \"npm:dev:css\"",
"test": "jest"
"test": "jest",
"screenshot": "node scripts/take-screenshots.js"
},
"keywords": [
"proxmox",
@@ -31,6 +32,7 @@
"conventional-changelog-cli": "^5.0.0",
"dotenv": "^16.5.0",
"jest": "^29.7.0",
"playwright": "^1.52.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.4"
}
+233
View File
@@ -0,0 +1,233 @@
const { chromium } = require('playwright');
const path = require('path');
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 WAIT_OPTIONS = { waitUntil: 'networkidle', timeout: 15000 }; // Increased timeout, networkidle
const OVERLAY_SELECTOR = '#loading-overlay';
// Define the sections to capture
// Placeholder selectors/navigation logic will need refinement
const sections = [
// Dashboard: Wait for guest row, capture FULL page
{ name: '01-dashboard',
fullPage: true,
action: async (page) => {
console.log(' Action: Waiting for dashboard content to load (checking for removal of loading text)...');
// Wait for the first row that DOES NOT contain the loading text TD
await page.locator('#main-table tbody tr:not(:has(td:text("Loading data...")))').first().waitFor({ state: 'visible', timeout: 30000 });
console.log(' Action: Dashboard content loaded (loading text gone).');
}
},
// Node View: Click tab, wait for table rows, capture nodes tab content
{ name: '02-node-view',
screenshotTarget: '#nodes',
action: async (page) => {
console.log(' Action: Clicking Nodes tab');
await page.locator('[data-tab="nodes"]').click();
console.log(' Action: Waiting for nodes table to be visible');
await page.locator('#nodes #nodes-table').waitFor({ state: 'visible', timeout: 10000 });
console.log(' Action: Nodes table visible');
// Optional: wait for rows as well, though table visibility might be enough
// await page.locator('#nodes-table-body tr').first().waitFor({ state: 'visible', timeout: 10000 });
}
},
// VM View: Click main tab, click VM filter, wait, capture main content
{ name: '03-vm-container-view', // Renaming slightly as it shows filtered list
screenshotTarget: '#main',
action: async (page) => {
console.log(' Action: Clicking Main tab (if not already active)');
// Ensure main tab is active first
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 });
}
// Wait for the VM filter button's LABEL to be visible before clicking
console.log(' Action: Waiting for VM filter label to be visible');
const vmFilterLabel = page.locator('label[for="filter-vm"]'); // Target the label now
await vmFilterLabel.waitFor({ state: 'visible', timeout: 15000 });
console.log(' Action: VM filter label visible');
console.log(' Action: Clicking VM filter label');
await vmFilterLabel.click(); // Click the label
await page.waitForTimeout(1000);
console.log(' Action: VM filter applied');
}
},
// PBS View: Click tab, wait for PBS container content, capture PBS tab content
{ name: '04-pbs-view',
screenshotTarget: '#pbs',
action: async (page) => {
console.log(' Action: Clicking PBS tab');
await page.locator('[data-tab="pbs"]').click();
console.log(' Action: Waiting for PBS container to be visible');
// Wait for the main container within the PBS tab to be visible
await page.locator('#pbs #pbs-instances-container').waitFor({ state: 'visible', timeout: 10000 });
console.log(' Action: PBS container visible');
// Optional: wait for actual content rows if needed
// await page.locator('#pbs-instances-container > *').first().waitFor({ state: 'visible', timeout: 10000 });
}
},
// Backups View: Click tab, wait for table content, capture backups tab content
{ name: '05-backups-view', // Added new section
screenshotTarget: '#backups',
action: async (page) => {
console.log(' Action: Clicking Backups tab');
await page.locator('[data-tab="backups"]').click();
console.log(' Action: Waiting for backups table row to be visible');
// Wait for the first row in the backups table body
await page.locator('#backups-overview-tbody tr').first().waitFor({ state: 'visible', timeout: 15000 });
console.log(' Action: Backups table row visible');
}
},
// { name: '06-task-view', url: '/#tasks', screenshotTarget: '#task-list-element', action: async (page) => { /* Navigate to task view if separate */ } }, // Uncomment and adjust if needed
];
async function takeScreenshots() {
console.log(`Starting screenshot capture for ${BASE_URL}...`);
console.log(`Outputting to: ${OUTPUT_DIR}`);
// --- 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);
let deletedCount = 0;
files.forEach(file => {
if (path.extname(file).toLowerCase() === '.png') {
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}`);
}
}
});
console.log(`Cleanup finished. Deleted ${deletedCount} PNG file(s).`);
} 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)
if (!fs.existsSync(OUTPUT_DIR)) {
console.log(`Creating directory: ${OUTPUT_DIR}`);
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
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();
console.log('Navigating to base URL and waiting for initial load...');
await page.goto(BASE_URL, WAIT_OPTIONS);
// 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}`);
} catch (error) {
console.error(` Failed to capture section ${section.name}: ${error.message}`);
// Optionally, decide if you want to continue or stop on error
}
}
} catch (error) {
console.error(`Error during screenshot process: ${error}`);
process.exitCode = 1; // Indicate failure
} finally {
if (browser) {
await browser.close();
console.log('Browser closed.');
}
}
console.log('Screenshot capture finished.');
}
takeScreenshots();