diff --git a/README.md b/README.md
index 12371b57d..bd45e3261 100644
--- a/README.md
+++ b/README.md
@@ -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.
-
+
+
+### 📸 Screenshots
+
+
+Click to view other UI tabs
+
+**Nodes Tab:**
+
+
+**Main Tab (Filtered for VMs):**
+
+
+**PBS Tab:**
+
+
+**Backups Tab:**
+
+
+
[](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://: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.*
-
-
-Alternative: Inline Variables in `docker-compose.yml` (Click to Expand)
-
-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
-```
-
-
-
-### 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://:7655`).
-
-
-Updating and Managing the LXC Installation (Click to Expand)
-
-**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`.
-
-
-
-### 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:
-[](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.
\ No newline at end of file
+ ```
\ No newline at end of file
diff --git a/docs/images/01-dashboard.png b/docs/images/01-dashboard.png
new file mode 100644
index 000000000..6a5ffb22a
Binary files /dev/null and b/docs/images/01-dashboard.png differ
diff --git a/docs/images/02-node-view.png b/docs/images/02-node-view.png
new file mode 100644
index 000000000..1e85fa14e
Binary files /dev/null and b/docs/images/02-node-view.png differ
diff --git a/docs/images/03-vm-container-view.png b/docs/images/03-vm-container-view.png
new file mode 100644
index 000000000..62dcd65b9
Binary files /dev/null and b/docs/images/03-vm-container-view.png differ
diff --git a/docs/images/04-pbs-view.png b/docs/images/04-pbs-view.png
new file mode 100644
index 000000000..b258d8edd
Binary files /dev/null and b/docs/images/04-pbs-view.png differ
diff --git a/docs/images/05-backups-view.png b/docs/images/05-backups-view.png
new file mode 100644
index 000000000..2a1fc1c74
Binary files /dev/null and b/docs/images/05-backups-view.png differ
diff --git a/docs/images/dashboard-screenshot.png b/docs/images/dashboard-screenshot.png
deleted file mode 100644
index 6438be695..000000000
Binary files a/docs/images/dashboard-screenshot.png and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index fcb786b70..abdc33d99 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index ec3e7c21b..915a4fa70 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/scripts/take-screenshots.js b/scripts/take-screenshots.js
new file mode 100644
index 000000000..a67835edc
--- /dev/null
+++ b/scripts/take-screenshots.js
@@ -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();
\ No newline at end of file