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. -![Pulse Dashboard](docs/images/dashboard-screenshot.png) +![Pulse Dashboard](docs/images/01-dashboard.png) + +### 📸 Screenshots + +
+Click to view other UI tabs + +**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) + +
[![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://: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: -[![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. \ 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