├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── claude.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── README.md ├── data ├── acknowledgements.json └── custom-thresholds.json ├── docker-compose.yml ├── docs ├── images │ ├── 01-dashboard.png │ ├── 02-pbs-view.png │ ├── 03-backups-view.png │ ├── 04-storage-view.png │ ├── 05-line-graph-toggle.png │ ├── 06-mobile-dashboard.png │ ├── 07-mobile-pbs-view.png │ └── 08-mobile-backups-view.png └── update-testing.md ├── package-lock.json ├── package.json ├── scripts ├── create-release.sh ├── diagnostics.sh ├── fix-css.sh ├── install-pulse.sh ├── take-screenshots.js └── test-update.sh ├── server ├── alertManager.js ├── apiClients.js ├── configApi.js ├── configLoader.js ├── customThresholds.js ├── dataFetcher.js ├── diagnostics.js ├── index.js ├── metricsHistory.js ├── pbsUtils.js ├── state.js ├── tests │ ├── apiClients.test.js │ ├── config.test.js │ ├── dataFetcher.test.js │ └── pbsUtils.test.js ├── thresholdRoutes.js └── updateManager.js └── src ├── index.css ├── postcss.config.js ├── public ├── index.html ├── js │ ├── alertsHandler.js │ ├── charts.js │ ├── config.js │ ├── debounce.js │ ├── hotReload.js │ ├── main.js │ ├── socketHandler.js │ ├── state.js │ ├── tabs.js │ ├── theme.js │ ├── tooltips.js │ ├── ui │ │ ├── backup-detail-card.js │ │ ├── backup-summary-cards.js │ │ ├── backups.js │ │ ├── calendar-heatmap.js │ │ ├── common.js │ │ ├── config-banner.js │ │ ├── dashboard.js │ │ ├── empty-states.js │ │ ├── loading-skeletons.js │ │ ├── nodes.js │ │ ├── pbs.js │ │ ├── settings.js │ │ ├── status-icons.js │ │ ├── storage.js │ │ └── thresholds.js │ ├── utils.js │ ├── virtual-scroll.js │ └── wcag-colors.js ├── logo.svg ├── logos │ └── pulse-logo-256x256.png └── setup.html └── tailwind.config.js /.env.example: -------------------------------------------------------------------------------- 1 | # Pulse Configuration Example 2 | # ---------------------------- 3 | 4 | # --- Proxmox VE Primary Endpoint (Required) --- 5 | # Only API Token authentication is supported. 6 | PROXMOX_HOST=your-proxmox-ip-or-hostname 7 | PROXMOX_TOKEN_ID=your-api-token-id@pam!your-token-name 8 | PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid 9 | # Optional: Specify custom port if not default 8006 10 | # PROXMOX_PORT=8006 11 | # Optional: Provide a display name for this endpoint in the UI 12 | # PROXMOX_NODE_NAME=My Primary Proxmox 13 | # Optional: Set to true to allow self-signed certificates (default: true) 14 | # PROXMOX_ALLOW_SELF_SIGNED_CERTS=true 15 | # Optional: Enable/Disable this endpoint (defaults to true if omitted) 16 | # PROXMOX_ENABLED=true 17 | 18 | # --- Proxmox VE Additional Endpoints (Optional) --- 19 | # Use consecutive numbers (_2, _3, ...) for additional PVE instances 20 | # Only API Token authentication is supported. 21 | # PROXMOX_HOST_2=second-proxmox-ip 22 | # PROXMOX_TOKEN_ID_2=second-token-id@pve!my-token 23 | # PROXMOX_TOKEN_SECRET_2=second-token-secret 24 | # PROXMOX_PORT_2=8006 25 | # PROXMOX_NODE_NAME_2=My Secondary Proxmox 26 | # PROXMOX_ALLOW_SELF_SIGNED_CERTS_2=true 27 | # PROXMOX_ENABLED_2=true 28 | 29 | # --- Proxmox Backup Server (PBS) Integration (Optional) --- 30 | # Only API Token authentication is supported. 31 | # Use consecutive numbers (_2, _3, ...) for additional PBS instances. 32 | 33 | # PBS Primary Instance 34 | # PBS_HOST=your-pbs-ip-or-hostname 35 | # PBS_TOKEN_ID=your-pbs-token-id@pbs!my-token 36 | # PBS_TOKEN_SECRET=your-pbs-token-secret 37 | # Optional: Specify custom port if not default 8007 38 | # PBS_PORT=8007 39 | # Required (Unless Token has Sys.Audit): Internal hostname of the PBS server. 40 | # Found using 'hostname' command on the PBS server via SSH. 41 | # See README for details on why this is usually required with API tokens. 42 | # PBS_NODE_NAME=your-pbs-internal-hostname 43 | # Optional: Set to true to allow self-signed certificates (default: true) 44 | # PBS_ALLOW_SELF_SIGNED_CERTS=true 45 | 46 | # PBS Additional Instance Example (suffix with _2, _3, etc.) 47 | # PBS_HOST_2=second-pbs-ip-or-hostname 48 | # PBS_TOKEN_ID_2=second-pbs-token-id@pbs!my-token 49 | # PBS_TOKEN_SECRET_2=second-pbs-token-secret 50 | # PBS_PORT_2=8007 51 | # PBS_NODE_NAME_2=second-pbs-internal-hostname 52 | # PBS_ALLOW_SELF_SIGNED_CERTS_2=true 53 | 54 | # --- Pulse Service Settings (Optional) --- 55 | # Interval in milliseconds for fetching detailed VM/Container metrics (default: 2000) 56 | # PULSE_METRIC_INTERVAL_MS=2000 57 | # Interval in milliseconds for fetching structural data (nodes, VM/CT list, storage) (default: 30000) 58 | # PULSE_DISCOVERY_INTERVAL_MS=30000 59 | 60 | # --- Alert System Configuration (Optional) --- 61 | # Enable/disable specific alert types (default: true for all) 62 | # ALERT_CPU_ENABLED=true 63 | # ALERT_MEMORY_ENABLED=true 64 | # ALERT_DISK_ENABLED=true 65 | # ALERT_DOWN_ENABLED=true 66 | 67 | # Alert thresholds (percentages, defaults shown) 68 | # ALERT_CPU_THRESHOLD=85 69 | # ALERT_MEMORY_THRESHOLD=90 70 | # ALERT_DISK_THRESHOLD=95 71 | 72 | # Alert durations - how long condition must persist before alerting (milliseconds) 73 | # ALERT_CPU_DURATION=300000 # 5 minutes 74 | # ALERT_MEMORY_DURATION=300000 # 5 minutes 75 | # ALERT_DISK_DURATION=600000 # 10 minutes 76 | # ALERT_DOWN_DURATION=60000 # 1 minute 77 | 78 | # --- Development Settings (Optional) --- 79 | # Enable/disable hot reloading for frontend changes (default: true) 80 | # ENABLE_HOT_RELOAD=true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: rcourtman 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Environment (please complete the following information):** 26 | - Pulse Version: [e.g., v3.0.2] 27 | - Proxmox VE Version: [e.g., 8.1.4] 28 | - Browser [e.g., Chrome 120, Firefox 119] 29 | - OS Hosting Pulse [e.g., Docker on Debian 12, LXC on Proxmox, Node.js on Ubuntu 22.04] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: rcourtman 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | Fixes # (*issue number*) 12 | 13 | ## Motivation and Context 14 | 15 | 16 | 17 | ## How Has This Been Tested? 18 | 19 | 20 | 21 | 22 | ## Screenshots (if appropriate): 23 | 24 | ## Types of changes 25 | 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 29 | 30 | ## Checklist: 31 | 32 | 33 | - [ ] My code follows the code style of this project. 34 | - [ ] I have read the **CONTRIBUTING** document. 35 | - [ ] I have added tests to cover my changes (if applicable). 36 | - [ ] All new and existing tests passed (if applicable). -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm in the root directory 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | target-branch: "main" 9 | # Optional: Add labels to Dependabot PRs 10 | labels: 11 | - "dependencies" 12 | - "dependabot" 13 | # Ignore updates for tailwindcss >= v4 due to build issues 14 | ignore: 15 | - dependency-name: "tailwindcss" 16 | versions: [">= 4.0.0"] 17 | 18 | # Enable version updates for npm in the server directory 19 | - package-ecosystem: "npm" 20 | directory: "/server" 21 | schedule: 22 | interval: "daily" 23 | target-branch: "main" 24 | labels: 25 | - "dependencies" 26 | - "dependabot" -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node dependencies 2 | node_modules/ 3 | server/node_modules/ 4 | 5 | # Environment variables 6 | .env 7 | server/.env 8 | 9 | # Logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | data/acknowledgements.json 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Build outputs 46 | build/ 47 | dist/ 48 | src/public/output.css 49 | 50 | # Optional files 51 | .DS_Store 52 | 53 | # Local Documentation (Not for Repo) 54 | RELEASE_PROCEDURE.md 55 | 56 | # Cursor IDE configuration/rules 57 | .cursor/ 58 | 59 | # macOS 60 | .DS_Store 61 | 62 | # Feature Ideas - Should not be tracked 63 | docs/feature_ideas/ 64 | 65 | # Dependency directories 66 | jspm_packages/ 67 | 68 | # Optional npm cache directory 69 | .npm 70 | 71 | # Optional eslint cache 72 | .eslintcache 73 | 74 | # Optional VS Code files 75 | .vscode/ 76 | 77 | # Test coverage 78 | /coverage/ 79 | 80 | # Local configuration files 81 | RELEASE_CHECKLIST.md 82 | AI_WORKFLOW.md 83 | AI_WORKFLOW_SETUP.md 84 | 85 | # Lockfiles 86 | # package-lock.json # Usually committed, but ignore if specified elsewhere 87 | server/package-lock.json 88 | yarn.lock 89 | pnpm-lock.yaml 90 | 91 | # Release tarballs 92 | *.tar.gz 93 | 94 | # Update mechanism 95 | backup/ 96 | temp/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html]. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [Mozilla's code of conduct enforcement ladder]: https://github.com/mozilla/diversity -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Pulse 2 | 3 | Thank you for your interest in contributing to Pulse! We appreciate your help. Here are some guidelines to follow: 4 | 5 | ## Reporting Bugs 6 | 7 | - Please ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/rcourtman/Pulse/issues). 8 | - If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/rcourtman/Pulse/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample or an executable test case** demonstrating the expected behavior that is not occurring. 9 | - Use the "Bug Report" issue template if available. 10 | 11 | ## Suggesting Enhancements 12 | 13 | - Open a new issue using the "Feature Request" template. 14 | - Clearly describe the enhancement you are proposing and why it would be beneficial. 15 | - Provide examples or mockups if possible. 16 | 17 | ## Pull Requests 18 | 19 | - Fork the repository and create your branch from `main`. 20 | - Ensure your code adheres to the project's existing style. 21 | - If you've added code that should be tested, add tests. 22 | - Ensure the test suite passes (if applicable). 23 | - Make sure your code lints (if linters are set up). 24 | - Issue that pull request! 25 | 26 | We will review your pull request and provide feedback. Thank you for your contribution! -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | This guide provides instructions for setting up and running the Pulse application directly using Node.js, typically for development or contribution purposes. Users looking to deploy Pulse should refer to the main [README.md](README.md) for Docker or LXC instructions. 4 | 5 | ## 💾 Installation (from Source) 6 | 7 | If you intend to run the application directly from source or contribute to development, you need to install dependencies. 8 | 9 | 1. **Clone the Repository:** 10 | ```bash 11 | git clone https://github.com/rcourtman/Pulse.git 12 | cd Pulse 13 | ``` 14 | 15 | 2. **Install Root Dependencies:** Navigate to the project root directory and install the necessary Node.js dependencies. 16 | ```bash 17 | # Install root dependencies 18 | npm install 19 | ``` 20 | 21 | 3. **Install Server Dependencies:** You also need to install dependencies specifically for the server component: 22 | ```bash 23 | # Install server dependencies 24 | cd server 25 | npm install 26 | cd .. 27 | ``` 28 | 29 | ## ▶️ Running the Application (Node.js) 30 | 31 | These instructions assume you have completed the installation steps above. 32 | 33 | ### Development Mode 34 | 35 | To run the application in development mode, which typically enables features like hot-reloading for easier testing of changes: 36 | 37 | ```bash 38 | npm run dev 39 | ``` 40 | This command starts the server (often using `nodemon` or a similar tool) which monitors for file changes and automatically restarts. Check the terminal output for the URL where the application is accessible (e.g., `http://localhost:7655`). 41 | 42 | ### Production Mode (Direct Node Execution) 43 | 44 | To run the application using a standard `node` process, similar to how it might run in production if not containerized: 45 | 46 | ```bash 47 | npm run start 48 | ``` 49 | This command starts the server using `node`. Access the application via the configured host and port (defaulting to `http://localhost:7655`). 50 | 51 | **Note:** Ensure your `.env` file is correctly configured in the project root directory before running either command. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Builder Stage ---- 2 | FROM node:18-alpine AS builder 3 | 4 | WORKDIR /usr/src/app 5 | 6 | # Install necessary build tools (if any, e.g., python, make for some native deps) 7 | # RUN apk add --no-cache ... 8 | 9 | # Copy only necessary package files first 10 | COPY package*.json ./ 11 | 12 | # Install ALL dependencies (including dev needed for build) 13 | # Using npm ci for faster, more reliable builds in CI/CD 14 | RUN npm ci 15 | 16 | # Copy the rest of the application code 17 | # Important: Copy . before running build commands 18 | COPY . . 19 | 20 | # Build the production CSS 21 | RUN npm run build:css 22 | 23 | # Prune devDependencies after build 24 | RUN npm prune --production 25 | 26 | # ---- Runner Stage ---- 27 | FROM node:18-alpine 28 | 29 | WORKDIR /usr/src/app 30 | 31 | # Use existing node user (uid:gid 1000:1000) instead of system service accounts 32 | # The node:18-alpine image already has a 'node' user with uid:gid 1000:1000 33 | 34 | # Copy necessary files from builder stage 35 | # Copy node_modules first (can be large) 36 | COPY --from=builder /usr/src/app/node_modules ./node_modules 37 | # Copy built assets 38 | COPY --from=builder /usr/src/app/src/public ./src/public 39 | # Copy server code 40 | COPY --from=builder /usr/src/app/server ./server 41 | # Copy root package.json needed for npm start and potentially other metadata 42 | COPY --from=builder /usr/src/app/package.json ./ 43 | # Optionally copy other root files if needed by the application (e.g., .env.example, README) 44 | # COPY --from=builder /usr/src/app/.env.example ./ 45 | 46 | # Create config directory for persistent volume mount and data directory 47 | RUN mkdir -p /usr/src/app/config /usr/src/app/data 48 | 49 | # Ensure correct ownership of application files 50 | # Use /usr/src/app to cover everything copied 51 | RUN chown -R node:node /usr/src/app 52 | 53 | # Switch to non-root user 54 | USER node 55 | 56 | # Set environment variable to indicate Docker deployment 57 | ENV DOCKER_DEPLOYMENT=true 58 | 59 | # Expose port 60 | EXPOSE 7655 61 | 62 | # Run the application using the start script 63 | CMD [ "npm", "run", "start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 R Courtman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /data/acknowledgements.json: -------------------------------------------------------------------------------- 1 | { 2 | "guest_down_primary_desktop_102": { 3 | "id": "alert_1748685507709_c0qu2nodx", 4 | "rule": { 5 | "id": "guest_down", 6 | "name": "Guest System Down", 7 | "description": "Virtual machine or container has stopped unexpectedly", 8 | "metric": "status", 9 | "condition": "equals", 10 | "threshold": "stopped", 11 | "duration": 60000, 12 | "severity": "critical", 13 | "enabled": true, 14 | "tags": [ 15 | "availability", 16 | "guest" 17 | ], 18 | "group": "availability_alerts", 19 | "escalationTime": 600000, 20 | "autoResolve": true, 21 | "suppressionTime": 120000, 22 | "notificationChannels": [ 23 | "default", 24 | "urgent" 25 | ] 26 | }, 27 | "guest": { 28 | "diskread": 0, 29 | "serial": 1, 30 | "vmid": 102, 31 | "cpus": 4, 32 | "name": "windows11", 33 | "netout": 0, 34 | "maxmem": 8589934592, 35 | "uptime": 0, 36 | "diskwrite": 0, 37 | "disk": 0, 38 | "cpu": 0, 39 | "netin": 0, 40 | "status": "stopped", 41 | "mem": 0, 42 | "maxdisk": 68719476736, 43 | "node": "desktop", 44 | "endpointId": "primary", 45 | "type": "qemu", 46 | "id": "primary-desktop-102" 47 | }, 48 | "startTime": 1748685507709, 49 | "lastUpdate": 1748688477886, 50 | "currentValue": "stopped", 51 | "state": "active", 52 | "escalated": true, 53 | "acknowledged": true, 54 | "triggeredAt": 1748685568177, 55 | "escalatedAt": 1748686208308, 56 | "acknowledgedBy": "bulk-operation", 57 | "acknowledgedAt": 1748688478340, 58 | "acknowledgeNote": "Bulk acknowledged via dropdown", 59 | "note": "Bulk acknowledged via dropdown" 60 | }, 61 | "guest_down_primary_desktop_400": { 62 | "id": "alert_1748685507709_1k93uym8r", 63 | "rule": { 64 | "id": "guest_down", 65 | "name": "Guest System Down", 66 | "description": "Virtual machine or container has stopped unexpectedly", 67 | "metric": "status", 68 | "condition": "equals", 69 | "threshold": "stopped", 70 | "duration": 60000, 71 | "severity": "critical", 72 | "enabled": true, 73 | "tags": [ 74 | "availability", 75 | "guest" 76 | ], 77 | "group": "availability_alerts", 78 | "escalationTime": 600000, 79 | "autoResolve": true, 80 | "suppressionTime": 120000, 81 | "notificationChannels": [ 82 | "default", 83 | "urgent" 84 | ] 85 | }, 86 | "guest": { 87 | "cpu": 0, 88 | "disk": 0, 89 | "maxdisk": 34359738368, 90 | "mem": 0, 91 | "status": "stopped", 92 | "netin": 0, 93 | "vmid": 400, 94 | "diskread": 0, 95 | "diskwrite": 0, 96 | "uptime": 0, 97 | "maxmem": 2147483648, 98 | "netout": 0, 99 | "name": "ubuntu-gpu-vm", 100 | "cpus": 2, 101 | "node": "desktop", 102 | "endpointId": "primary", 103 | "type": "qemu", 104 | "id": "primary-desktop-400" 105 | }, 106 | "startTime": 1748685507709, 107 | "lastUpdate": 1748688477886, 108 | "currentValue": "stopped", 109 | "state": "active", 110 | "escalated": true, 111 | "acknowledged": true, 112 | "triggeredAt": 1748685568177, 113 | "escalatedAt": 1748686208307, 114 | "acknowledgedBy": "bulk-operation", 115 | "acknowledgedAt": 1748688478335, 116 | "acknowledgeNote": "Bulk acknowledged via dropdown", 117 | "note": "Bulk acknowledged via dropdown" 118 | }, 119 | "guest_down_primary_desktop_200": { 120 | "id": "alert_1748685507709_i4z21lcww", 121 | "rule": { 122 | "id": "guest_down", 123 | "name": "Guest System Down", 124 | "description": "Virtual machine or container has stopped unexpectedly", 125 | "metric": "status", 126 | "condition": "equals", 127 | "threshold": "stopped", 128 | "duration": 60000, 129 | "severity": "critical", 130 | "enabled": true, 131 | "tags": [ 132 | "availability", 133 | "guest" 134 | ], 135 | "group": "availability_alerts", 136 | "escalationTime": 600000, 137 | "autoResolve": true, 138 | "suppressionTime": 120000, 139 | "notificationChannels": [ 140 | "default", 141 | "urgent" 142 | ] 143 | }, 144 | "guest": { 145 | "diskread": 0, 146 | "vmid": 200, 147 | "diskwrite": 0, 148 | "name": "UnraidServer", 149 | "cpus": 6, 150 | "maxmem": 8589934592, 151 | "netout": 0, 152 | "uptime": 0, 153 | "cpu": 0, 154 | "disk": 0, 155 | "maxdisk": 0, 156 | "netin": 0, 157 | "status": "stopped", 158 | "mem": 0, 159 | "node": "desktop", 160 | "endpointId": "primary", 161 | "type": "qemu", 162 | "id": "primary-desktop-200" 163 | }, 164 | "startTime": 1748685507709, 165 | "lastUpdate": 1748688477886, 166 | "currentValue": "stopped", 167 | "state": "active", 168 | "escalated": true, 169 | "acknowledged": true, 170 | "triggeredAt": 1748685568177, 171 | "escalatedAt": 1748686208308, 172 | "acknowledgedBy": "bulk-operation", 173 | "acknowledgedAt": 1748688478343, 174 | "acknowledgeNote": "Bulk acknowledged via dropdown", 175 | "note": "Bulk acknowledged via dropdown" 176 | }, 177 | "guest_down_primary_desktop_109": { 178 | "id": "alert_1748685507710_i0hk9rnyu", 179 | "rule": { 180 | "id": "guest_down", 181 | "name": "Guest System Down", 182 | "description": "Virtual machine or container has stopped unexpectedly", 183 | "metric": "status", 184 | "condition": "equals", 185 | "threshold": "stopped", 186 | "duration": 60000, 187 | "severity": "critical", 188 | "enabled": true, 189 | "tags": [ 190 | "availability", 191 | "guest" 192 | ], 193 | "group": "availability_alerts", 194 | "escalationTime": 600000, 195 | "autoResolve": true, 196 | "suppressionTime": 120000, 197 | "notificationChannels": [ 198 | "default", 199 | "urgent" 200 | ] 201 | }, 202 | "guest": { 203 | "diskwrite": 0, 204 | "type": "lxc", 205 | "swap": 0, 206 | "maxswap": 536870912, 207 | "cpus": 4, 208 | "name": "pbs2", 209 | "maxmem": 4294967296, 210 | "netout": 0, 211 | "uptime": 0, 212 | "diskread": 0, 213 | "vmid": 109, 214 | "tags": "backup;community-script", 215 | "maxdisk": 10737418240, 216 | "netin": 0, 217 | "mem": 0, 218 | "status": "stopped", 219 | "cpu": 0, 220 | "disk": 0, 221 | "node": "desktop", 222 | "endpointId": "primary", 223 | "id": "primary-desktop-109" 224 | }, 225 | "startTime": 1748685507709, 226 | "lastUpdate": 1748688477886, 227 | "currentValue": "stopped", 228 | "state": "active", 229 | "escalated": true, 230 | "acknowledged": true, 231 | "triggeredAt": 1748685568177, 232 | "escalatedAt": 1748686208308, 233 | "acknowledgedBy": "bulk-operation", 234 | "acknowledgedAt": 1748688478351, 235 | "acknowledgeNote": "Bulk acknowledged via dropdown", 236 | "note": "Bulk acknowledged via dropdown" 237 | }, 238 | "guest_down_primary_desktop_111": { 239 | "id": "alert_1748685507710_sgmqk37dq", 240 | "rule": { 241 | "id": "guest_down", 242 | "name": "Guest System Down", 243 | "description": "Virtual machine or container has stopped unexpectedly", 244 | "metric": "status", 245 | "condition": "equals", 246 | "threshold": "stopped", 247 | "duration": 60000, 248 | "severity": "critical", 249 | "enabled": true, 250 | "tags": [ 251 | "availability", 252 | "guest" 253 | ], 254 | "group": "availability_alerts", 255 | "escalationTime": 600000, 256 | "autoResolve": true, 257 | "suppressionTime": 120000, 258 | "notificationChannels": [ 259 | "default", 260 | "urgent" 261 | ] 262 | }, 263 | "guest": { 264 | "maxdisk": 2147483648, 265 | "netin": 0, 266 | "mem": 0, 267 | "status": "stopped", 268 | "cpu": 0, 269 | "disk": 0, 270 | "diskwrite": 0, 271 | "type": "lxc", 272 | "swap": 0, 273 | "maxswap": 536870912, 274 | "name": "debian", 275 | "cpus": 1, 276 | "netout": 0, 277 | "maxmem": 536870912, 278 | "uptime": 0, 279 | "diskread": 0, 280 | "vmid": 111, 281 | "tags": "community-script;os", 282 | "node": "desktop", 283 | "endpointId": "primary", 284 | "id": "primary-desktop-111" 285 | }, 286 | "startTime": 1748685507709, 287 | "lastUpdate": 1748688477886, 288 | "currentValue": "stopped", 289 | "state": "active", 290 | "escalated": true, 291 | "acknowledged": true, 292 | "triggeredAt": 1748685568177, 293 | "escalatedAt": 1748686208308, 294 | "acknowledgedBy": "bulk-operation", 295 | "acknowledgedAt": 1748688478347, 296 | "acknowledgeNote": "Bulk acknowledged via dropdown", 297 | "note": "Bulk acknowledged via dropdown" 298 | }, 299 | "guest_down_endpoint_2_pi_100": { 300 | "id": "alert_1748685507710_lujuqfuca", 301 | "rule": { 302 | "id": "guest_down", 303 | "name": "Guest System Down", 304 | "description": "Virtual machine or container has stopped unexpectedly", 305 | "metric": "status", 306 | "condition": "equals", 307 | "threshold": "stopped", 308 | "duration": 60000, 309 | "severity": "critical", 310 | "enabled": true, 311 | "tags": [ 312 | "availability", 313 | "guest" 314 | ], 315 | "group": "availability_alerts", 316 | "escalationTime": 600000, 317 | "autoResolve": true, 318 | "suppressionTime": 120000, 319 | "notificationChannels": [ 320 | "default", 321 | "urgent" 322 | ] 323 | }, 324 | "guest": { 325 | "maxdisk": 6442450944, 326 | "disk": 0, 327 | "name": "pihole", 328 | "uptime": 0, 329 | "diskread": 0, 330 | "netin": 0, 331 | "maxswap": 536870912, 332 | "type": "lxc", 333 | "netout": 0, 334 | "vmid": 100, 335 | "cpu": 0, 336 | "diskwrite": 0, 337 | "cpus": 1, 338 | "swap": 0, 339 | "tags": "proxmox-helper-scripts", 340 | "status": "stopped", 341 | "maxmem": 536870912, 342 | "mem": 0, 343 | "node": "pi", 344 | "endpointId": "endpoint_2", 345 | "id": "endpoint_2-pi-100" 346 | }, 347 | "startTime": 1748685507709, 348 | "lastUpdate": 1748688477886, 349 | "currentValue": "stopped", 350 | "state": "active", 351 | "escalated": true, 352 | "acknowledged": true, 353 | "triggeredAt": 1748685568177, 354 | "escalatedAt": 1748686208308, 355 | "acknowledgedBy": "bulk-operation", 356 | "acknowledgedAt": 1748688478362, 357 | "acknowledgeNote": "Bulk acknowledged via dropdown", 358 | "note": "Bulk acknowledged via dropdown" 359 | }, 360 | "guest_down_endpoint_2_pi_102": { 361 | "id": "alert_1748685507710_64sv65hre", 362 | "rule": { 363 | "id": "guest_down", 364 | "name": "Guest System Down", 365 | "description": "Virtual machine or container has stopped unexpectedly", 366 | "metric": "status", 367 | "condition": "equals", 368 | "threshold": "stopped", 369 | "duration": 60000, 370 | "severity": "critical", 371 | "enabled": true, 372 | "tags": [ 373 | "availability", 374 | "guest" 375 | ], 376 | "group": "availability_alerts", 377 | "escalationTime": 600000, 378 | "autoResolve": true, 379 | "suppressionTime": 120000, 380 | "notificationChannels": [ 381 | "default", 382 | "urgent" 383 | ] 384 | }, 385 | "guest": { 386 | "diskwrite": 0, 387 | "cpu": 0, 388 | "tags": "proxmox-helper-scripts", 389 | "swap": 0, 390 | "cpus": 1, 391 | "mem": 0, 392 | "maxmem": 536870912, 393 | "status": "stopped", 394 | "name": "pi-docker", 395 | "disk": 0, 396 | "maxdisk": 4401922048, 397 | "netin": 0, 398 | "uptime": 0, 399 | "diskread": 0, 400 | "type": "lxc", 401 | "maxswap": 536870912, 402 | "vmid": 102, 403 | "netout": 0, 404 | "node": "pi", 405 | "endpointId": "endpoint_2", 406 | "id": "endpoint_2-pi-102" 407 | }, 408 | "startTime": 1748685507709, 409 | "lastUpdate": 1748688477886, 410 | "currentValue": "stopped", 411 | "state": "active", 412 | "escalated": true, 413 | "acknowledged": true, 414 | "triggeredAt": 1748685568177, 415 | "escalatedAt": 1748686208308, 416 | "acknowledgedBy": "bulk-operation", 417 | "acknowledgedAt": 1748688478358, 418 | "acknowledgeNote": "Bulk acknowledged via dropdown", 419 | "note": "Bulk acknowledged via dropdown" 420 | }, 421 | "guest_down_endpoint_2_pi_101": { 422 | "id": "alert_1748685507710_qh85y8kjf", 423 | "rule": { 424 | "id": "guest_down", 425 | "name": "Guest System Down", 426 | "description": "Virtual machine or container has stopped unexpectedly", 427 | "metric": "status", 428 | "condition": "equals", 429 | "threshold": "stopped", 430 | "duration": 60000, 431 | "severity": "critical", 432 | "enabled": true, 433 | "tags": [ 434 | "availability", 435 | "guest" 436 | ], 437 | "group": "availability_alerts", 438 | "escalationTime": 600000, 439 | "autoResolve": true, 440 | "suppressionTime": 120000, 441 | "notificationChannels": [ 442 | "default", 443 | "urgent" 444 | ] 445 | }, 446 | "guest": { 447 | "diskwrite": 0, 448 | "cpu": 0, 449 | "tags": "alpine;community-script;os", 450 | "cpus": 1, 451 | "swap": 0, 452 | "status": "stopped", 453 | "maxmem": 536870912, 454 | "mem": 0, 455 | "disk": 0, 456 | "name": "pi-influxdb", 457 | "maxdisk": 2254438400, 458 | "netin": 0, 459 | "diskread": 0, 460 | "uptime": 0, 461 | "maxswap": 536870912, 462 | "type": "lxc", 463 | "vmid": 101, 464 | "netout": 0, 465 | "node": "pi", 466 | "endpointId": "endpoint_2", 467 | "id": "endpoint_2-pi-101" 468 | }, 469 | "startTime": 1748685507709, 470 | "lastUpdate": 1748688477886, 471 | "currentValue": "stopped", 472 | "state": "active", 473 | "escalated": true, 474 | "acknowledged": true, 475 | "triggeredAt": 1748685568177, 476 | "escalatedAt": 1748686208308, 477 | "acknowledgedBy": "bulk-operation", 478 | "acknowledgedAt": 1748688478354, 479 | "acknowledgeNote": "Bulk acknowledged via dropdown", 480 | "note": "Bulk acknowledged via dropdown" 481 | } 482 | } -------------------------------------------------------------------------------- /data/custom-thresholds.json: -------------------------------------------------------------------------------- 1 | { 2 | "primary:103": { 3 | "endpointId": "primary", 4 | "nodeId": "auto-detect", 5 | "vmid": "103", 6 | "thresholds": { 7 | "disk": { 8 | "warning": 70, 9 | "critical": 80 10 | } 11 | }, 12 | "enabled": true, 13 | "createdAt": "2025-06-01T19:50:34.784Z", 14 | "updatedAt": "2025-06-01T19:50:34.784Z" 15 | } 16 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pulse-server: 3 | # Build context commented out - using pre-built image from Docker Hub 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | # image: rcourtman/pulse:latest # Use the pre-built image from Docker Hub 8 | container_name: pulse 9 | restart: unless-stopped 10 | user: "1000:1000" # Run as standard user, not system service accounts 11 | ports: 12 | # Map container port 7655 to host port 7655 13 | # You can change the host port (left side) if 7655 is already in use on your host 14 | - "7655:7655" 15 | # env_file: 16 | # NOTE: .env file is now managed by the web UI and stored in persistent volume 17 | # No need to load from host .env file 18 | # - .env 19 | volumes: 20 | # Persist configuration data to avoid losing settings on container recreation 21 | # Mount a persistent volume for configuration files 22 | - pulse_config:/usr/src/app/config 23 | # Optional: Define networks if needed, otherwise uses default bridge network 24 | # networks: 25 | # - pulse_network 26 | 27 | # Define persistent volumes for configuration and data 28 | volumes: 29 | pulse_config: 30 | driver: local 31 | 32 | # Optional: Define a network 33 | # networks: 34 | # pulse_network: 35 | # driver: bridge -------------------------------------------------------------------------------- /docs/images/01-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/01-dashboard.png -------------------------------------------------------------------------------- /docs/images/02-pbs-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/02-pbs-view.png -------------------------------------------------------------------------------- /docs/images/03-backups-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/03-backups-view.png -------------------------------------------------------------------------------- /docs/images/04-storage-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/04-storage-view.png -------------------------------------------------------------------------------- /docs/images/05-line-graph-toggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/05-line-graph-toggle.png -------------------------------------------------------------------------------- /docs/images/06-mobile-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/06-mobile-dashboard.png -------------------------------------------------------------------------------- /docs/images/07-mobile-pbs-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/07-mobile-pbs-view.png -------------------------------------------------------------------------------- /docs/images/08-mobile-backups-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/docs/images/08-mobile-backups-view.png -------------------------------------------------------------------------------- /docs/update-testing.md: -------------------------------------------------------------------------------- 1 | # Update Mechanism Testing 2 | 3 | This document describes how to test the Pulse update mechanism without creating real GitHub releases. 4 | 5 | ## Test Mode 6 | 7 | The update system includes a test mode that simulates available updates without requiring actual releases. 8 | 9 | ### How to Use 10 | 11 | 1. **Start the server in test mode:** 12 | ```bash 13 | ./scripts/test-update.sh 14 | ``` 15 | 16 | Or with a custom version: 17 | ```bash 18 | ./scripts/test-update.sh 5.0.0 19 | ``` 20 | 21 | 2. **Open Pulse in your browser** and go to Settings 22 | 23 | 3. **Click "Check for Updates"** - you should see version 99.99.99 (or your custom version) available 24 | 25 | 4. **Click "Apply Update"** to test the update process 26 | 27 | ### What Happens in Test Mode 28 | 29 | - The update check returns a mock release with version 99.99.99 (or custom) 30 | - A mock tarball is created on-the-fly from current application files 31 | - The update process runs normally but with the test package 32 | - You can test the entire flow: download, backup, extraction, restart 33 | 34 | ### Environment Variables 35 | 36 | - `UPDATE_TEST_MODE=true` - Enables test mode 37 | - `UPDATE_TEST_VERSION=X.X.X` - Sets the mock version number (default: 99.99.99) 38 | 39 | ### Manual Testing 40 | 41 | You can also manually set the environment variables: 42 | 43 | ```bash 44 | UPDATE_TEST_MODE=true UPDATE_TEST_VERSION=10.0.0 npm run dev:server 45 | ``` 46 | 47 | ### Debugging 48 | 49 | When in test mode, check the console for: 50 | - `[UpdateManager] Test mode enabled, returning mock update info` - Confirms test mode is active 51 | - Asset names and download URLs in browser console if update fails 52 | - Server logs for the update process steps 53 | 54 | ### Notes 55 | 56 | - The mock tarball excludes node_modules, .git, temp, and backup directories 57 | - The test download URL is: `http://localhost:3000/api/test/mock-update.tar.gz` 58 | - This endpoint only works when `UPDATE_TEST_MODE=true` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pulse", 3 | "version": "3.19.0", 4 | "description": "A lightweight monitoring application for Proxmox VE.", 5 | "main": "server/index.js", 6 | "scripts": { 7 | "start": "node server/index.js", 8 | "dev:server": "NODE_ENV=development node -r dotenv/config server/index.js", 9 | "dev:css": "tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css --watch", 10 | "build:css": "NODE_ENV=production tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css", 11 | "dev": "concurrently --kill-others --kill-others-on-fail \"npm:dev:server\" \"npm:dev:css\"", 12 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage", 13 | "screenshot": "node scripts/take-screenshots.js" 14 | }, 15 | "keywords": [ 16 | "proxmox", 17 | "monitoring", 18 | "dashboard", 19 | "nodejs", 20 | "vuejs" 21 | ], 22 | "author": "Richard Courtman", 23 | "license": "MIT", 24 | "dependencies": { 25 | "axios": "^1.9.0", 26 | "axios-retry": "^4.5.0", 27 | "compression": "^1.7.5", 28 | "concurrently": "^9.1.2", 29 | "cors": "^2.8.5", 30 | "dotenv": "^16.5.0", 31 | "express": "^4.21.2", 32 | "nodemailer": "^6.9.18", 33 | "p-limit": "^6.2.0", 34 | "semver": "^7.7.2", 35 | "socket.io": "^4.7.2", 36 | "sqlite3": "^5.1.7", 37 | "tar": "^7.4.3" 38 | }, 39 | "_comment_tailwind_v3_reason": "Using Tailwind CSS v3 (3.4.4) due to build inconsistencies observed with v4 (specifically 4.1.4). v4 resulted in incorrectly purged CSS files when built within certain Linux environments (e.g., Proxmox LXC - Debian 12 x86_64), failing to detect dynamically added classes. v3.4.4 builds correctly.", 40 | "devDependencies": { 41 | "@gradin/tailwindcss-scrollbar": "^3.0.1", 42 | "autoprefixer": "^10.4.21", 43 | "chokidar": "^4.0.3", 44 | "concurrently": "^9.1.2", 45 | "conventional-changelog-cli": "^5.0.0", 46 | "cross-env": "^7.0.3", 47 | "jest": "^29.7.0", 48 | "playwright": "^1.52.0", 49 | "postcss": "^8.5.3", 50 | "tailwindcss": "^3.4.4" 51 | }, 52 | "jest": { 53 | "transformIgnorePatterns": [ 54 | "/node_modules/(?!p-limit|yocto-queue)/" 55 | ] 56 | }, 57 | "overrides": { 58 | "glob": "^10.4.5" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/create-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # Exit immediately if a command exits with a non-zero status. 3 | 4 | # This script creates a release tarball for Pulse 5 | # Note: COPYFILE_DISABLE=1 is used when creating the tarball to prevent 6 | # macOS extended attributes from being included, which would cause 7 | # "Ignoring unknown extended header keyword" warnings on extraction 8 | 9 | # --- Configuration --- 10 | # Attempt to get version from package.json 11 | PACKAGE_VERSION=$(node -p "require('./package.json').version") 12 | # Suggest a release version by stripping common pre-release suffixes like -dev.X or -alpha.X etc. 13 | SUGGESTED_RELEASE_VERSION=$(echo "$PACKAGE_VERSION" | sed -E 's/-(dev|alpha|beta|rc|pre)[-.0-9]*$//') 14 | 15 | # --- User Input for Version --- 16 | echo "Current version in package.json: $PACKAGE_VERSION" 17 | read -p "Enter release version (default: $SUGGESTED_RELEASE_VERSION): " USER_VERSION 18 | RELEASE_VERSION=${USER_VERSION:-$SUGGESTED_RELEASE_VERSION} 19 | 20 | if [[ -z "$RELEASE_VERSION" ]]; then 21 | echo "Error: Release version cannot be empty." 22 | exit 1 23 | fi 24 | echo "Creating release for version: v$RELEASE_VERSION" 25 | 26 | # --- Definitions --- 27 | APP_NAME="pulse" # Or derive from package.json if preferred 28 | RELEASE_DIR_NAME="${APP_NAME}-v${RELEASE_VERSION}" 29 | STAGING_PARENT_DIR="pulse-release-staging" # Temporary parent for the release content 30 | STAGING_FULL_PATH="$STAGING_PARENT_DIR/$RELEASE_DIR_NAME" 31 | TARBALL_NAME="${RELEASE_DIR_NAME}.tar.gz" 32 | 33 | # --- Cleanup Previous Attempts --- 34 | echo "Cleaning up previous attempts..." 35 | rm -rf "$STAGING_PARENT_DIR" 36 | rm -f "$TARBALL_NAME" 37 | mkdir -p "$STAGING_FULL_PATH" 38 | 39 | # --- Build Step --- 40 | echo "Building CSS..." 41 | npm run build:css 42 | if [ ! -f "src/public/output.css" ]; then 43 | echo "Error: src/public/output.css not found after build. Aborting." 44 | exit 1 45 | fi 46 | 47 | # --- Strip Extended Attributes First --- 48 | echo "Stripping macOS extended attributes from source files..." 49 | find . -type f \( -name "*.js" -o -name "*.json" -o -name "*.md" -o -name "*.css" -o -name "*.html" -o -name "*.sh" \) -exec xattr -c {} \; 2>/dev/null || true 50 | 51 | # --- Copy Application Files --- 52 | echo "Copying application files to $STAGING_FULL_PATH..." 53 | 54 | # Server files (excluding tests) 55 | echo "Copying server files..." 56 | COPYFILE_DISABLE=1 rsync -av --progress server/ "$STAGING_FULL_PATH/server/" --exclude 'tests/' 57 | 58 | # Source files (including built CSS, Tailwind config, and public assets) 59 | echo "Copying source files..." 60 | mkdir -p "$STAGING_FULL_PATH/src" # Ensure parent directory exists 61 | COPYFILE_DISABLE=1 rsync -av --progress src/public/ "$STAGING_FULL_PATH/src/public/" 62 | 63 | # Copy CSS build files and config 64 | COPYFILE_DISABLE=1 cp src/index.css "$STAGING_FULL_PATH/src/" 2>/dev/null || echo "Warning: src/index.css not found" 65 | COPYFILE_DISABLE=1 cp src/tailwind.config.js "$STAGING_FULL_PATH/src/" 2>/dev/null || echo "Warning: src/tailwind.config.js not found" 66 | COPYFILE_DISABLE=1 cp src/postcss.config.js "$STAGING_FULL_PATH/src/" 2>/dev/null || echo "Warning: src/postcss.config.js not found" 67 | 68 | # Root files 69 | echo "Copying root files..." 70 | COPYFILE_DISABLE=1 cp package.json "$STAGING_FULL_PATH/" 71 | COPYFILE_DISABLE=1 cp package-lock.json "$STAGING_FULL_PATH/" 72 | COPYFILE_DISABLE=1 cp README.md "$STAGING_FULL_PATH/" 73 | COPYFILE_DISABLE=1 cp LICENSE "$STAGING_FULL_PATH/" 74 | COPYFILE_DISABLE=1 cp CHANGELOG.md "$STAGING_FULL_PATH/" 75 | # .env.example no longer needed - configuration is now done via web interface 76 | 77 | # Scripts (e.g., install-pulse.sh, if intended for end-user) 78 | if [ -d "scripts" ]; then 79 | echo "Copying scripts..." 80 | mkdir -p "$STAGING_FULL_PATH/scripts/" 81 | if [ -f "scripts/install-pulse.sh" ]; then 82 | COPYFILE_DISABLE=1 cp scripts/install-pulse.sh "$STAGING_FULL_PATH/scripts/" 83 | fi 84 | # Add other scripts if they are part of the release 85 | fi 86 | 87 | # Docs 88 | if [ -d "docs" ]; then 89 | echo "Copying docs..." 90 | COPYFILE_DISABLE=1 rsync -av --progress docs/ "$STAGING_FULL_PATH/docs/" 91 | fi 92 | 93 | # --- Install Production Dependencies --- 94 | echo "Installing production dependencies in $STAGING_FULL_PATH..." 95 | (cd "$STAGING_FULL_PATH" && npm install --omit=dev --ignore-scripts) 96 | # --ignore-scripts prevents any package's own postinstall scripts from running during this build phase. 97 | # If your production dependencies have essential postinstall scripts, you might remove --ignore-scripts. 98 | 99 | # --- Verify Essential Files --- 100 | echo "Verifying essential files for tarball installation..." 101 | MISSING_FILES="" 102 | [ ! -f "$STAGING_FULL_PATH/package.json" ] && MISSING_FILES="$MISSING_FILES package.json" 103 | # .env.example no longer required - web-based configuration 104 | [ ! -f "$STAGING_FULL_PATH/server/index.js" ] && MISSING_FILES="$MISSING_FILES server/index.js" 105 | [ ! -f "$STAGING_FULL_PATH/src/public/output.css" ] && MISSING_FILES="$MISSING_FILES src/public/output.css" 106 | [ ! -d "$STAGING_FULL_PATH/node_modules" ] && MISSING_FILES="$MISSING_FILES node_modules/" 107 | 108 | if [ -n "$MISSING_FILES" ]; then 109 | echo "Error: Missing essential files for tarball installation:$MISSING_FILES" 110 | echo "The install script expects these files to be present in the tarball." 111 | exit 1 112 | fi 113 | echo "✅ All essential files verified for tarball installation." 114 | 115 | # --- Final Extended Attributes Cleanup --- 116 | echo "Final cleanup: Stripping extended attributes from staging directory..." 117 | find "$STAGING_PARENT_DIR" -type f -exec xattr -c {} \; 2>/dev/null || true 118 | 119 | # --- Create Tarball --- 120 | echo "Creating tarball: $TARBALL_NAME..." 121 | 122 | # Detect and use GNU tar if available (preferred on macOS to avoid extended attributes) 123 | TAR_CMD="tar" 124 | if command -v gtar &> /dev/null; then 125 | TAR_CMD="gtar" 126 | echo "Using GNU tar to avoid macOS extended attributes" 127 | elif tar --version 2>&1 | grep -q "GNU tar"; then 128 | TAR_CMD="tar" 129 | echo "Using GNU tar" 130 | else 131 | TAR_CMD="tar" 132 | echo "Using system tar with COPYFILE_DISABLE=1" 133 | fi 134 | 135 | # Go into the parent of the directory to be tarred to avoid leading paths in tarball 136 | if [ "$TAR_CMD" = "gtar" ]; then 137 | # GNU tar doesn't need COPYFILE_DISABLE and handles extended attributes properly 138 | (cd "$STAGING_PARENT_DIR" && "$TAR_CMD" -czf "../$TARBALL_NAME" "$RELEASE_DIR_NAME") 139 | else 140 | # BSD tar (macOS) needs COPYFILE_DISABLE=1 to prevent extended attributes 141 | (cd "$STAGING_PARENT_DIR" && COPYFILE_DISABLE=1 "$TAR_CMD" -czf "../$TARBALL_NAME" "$RELEASE_DIR_NAME") 142 | fi 143 | 144 | # --- Cleanup --- 145 | echo "Cleaning up staging directory ($STAGING_PARENT_DIR)..." 146 | rm -rf "$STAGING_PARENT_DIR" 147 | 148 | echo "" 149 | echo "----------------------------------------------------" 150 | echo "Release tarball created: $TARBALL_NAME" 151 | echo "----------------------------------------------------" 152 | echo "📦 This tarball includes:" 153 | echo " ✅ Pre-built CSS assets" 154 | echo " ✅ Production npm dependencies" 155 | echo " ✅ All server and client files" 156 | echo " ✅ Installation scripts" 157 | echo "" 158 | echo "🚀 Installation options:" 159 | echo "1. RECOMMENDED: Use the install script (faster, automated):" 160 | echo " curl -sLO https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-pulse.sh" 161 | echo " chmod +x install-pulse.sh" 162 | echo " sudo ./install-pulse.sh" 163 | echo " (The script will automatically use this tarball for faster installation)" 164 | echo "" 165 | echo "2. Manual installation:" 166 | echo " - Copy $TARBALL_NAME to target server" 167 | echo " - Extract: tar -xzf $TARBALL_NAME" 168 | echo " - Navigate: cd $RELEASE_DIR_NAME" 169 | echo " - Start: npm start" 170 | echo " - Configure via web interface at http://localhost:7655" 171 | echo "----------------------------------------------------" 172 | -------------------------------------------------------------------------------- /scripts/diagnostics.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Pulse Diagnostics Script 4 | # This script collects diagnostic information to help troubleshoot issues 5 | 6 | echo "================================" 7 | echo "Pulse Diagnostics Report" 8 | echo "================================" 9 | echo "" 10 | 11 | # Check if Pulse is running 12 | PORT=${PORT:-7655} 13 | HOST="localhost" 14 | 15 | # Function to check if Pulse is accessible 16 | check_pulse_running() { 17 | echo "Checking if Pulse is running on port $PORT..." 18 | if curl -s -o /dev/null -w "%{http_code}" "http://$HOST:$PORT/api/health" | grep -q "200"; then 19 | echo "✓ Pulse is running and accessible" 20 | return 0 21 | else 22 | echo "✗ Pulse is not accessible on http://$HOST:$PORT" 23 | echo "" 24 | echo "Please ensure Pulse is running before running diagnostics." 25 | echo "If using Docker: docker logs pulse" 26 | echo "If using systemd: sudo journalctl -u pulse-monitor.service -n 50" 27 | return 1 28 | fi 29 | } 30 | 31 | # Function to fetch and display diagnostics 32 | run_diagnostics() { 33 | echo "" 34 | echo "Fetching diagnostic information..." 35 | echo "" 36 | 37 | # Fetch diagnostics from the API 38 | RESPONSE=$(curl -s "http://$HOST:$PORT/api/diagnostics") 39 | 40 | if [ -z "$RESPONSE" ]; then 41 | echo "Failed to fetch diagnostics from Pulse API" 42 | return 1 43 | fi 44 | 45 | # Save full report to file 46 | TIMESTAMP=$(date +%Y%m%d_%H%M%S) 47 | REPORT_FILE="pulse_diagnostics_${TIMESTAMP}.json" 48 | echo "$RESPONSE" > "$REPORT_FILE" 49 | echo "Full diagnostic report saved to: $REPORT_FILE" 50 | echo "" 51 | 52 | # Parse and display key information using jq if available 53 | if command -v jq &> /dev/null; then 54 | echo "=== SUMMARY ===" 55 | echo "$RESPONSE" | jq -r '.summary | "Critical Issues: \(.criticalIssues)\nWarnings: \(.warnings)"' 56 | echo "" 57 | 58 | echo "=== CONFIGURATION ===" 59 | echo "Proxmox Instances:" 60 | echo "$RESPONSE" | jq -r '.configuration.proxmox[] | " Instance \(.index): \(.host) (Node: \(.node_name), Auth: \(.auth_type), Self-signed: \(.self_signed_certs))"' 61 | echo "" 62 | echo "PBS Instances:" 63 | echo "$RESPONSE" | jq -r '.configuration.pbs[] | " Instance \(.index): \(.host) (Node: \(.node_name), Auth: \(.auth_type), Self-signed: \(.self_signed_certs))"' 64 | echo "" 65 | 66 | echo "=== CONNECTIVITY ===" 67 | echo "Proxmox Connections:" 68 | echo "$RESPONSE" | jq -r '.connectivity.proxmox[] | " Instance \(.index): \(if .reachable then "✓ Reachable" else "✗ Unreachable" end) \(if .authValid then "(Auth: ✓)" else "(Auth: ✗)" end) \(if .error then "- Error: \(.error)" else "" end)"' 69 | echo "" 70 | echo "PBS Connections:" 71 | echo "$RESPONSE" | jq -r '.connectivity.pbs[] | " Instance \(.index): \(if .reachable then "✓ Reachable" else "✗ Unreachable" end) \(if .authValid then "(Auth: ✓)" else "(Auth: ✗)" end) \(if .error then "- Error: \(.error)" else "" end)"' 72 | echo "" 73 | 74 | echo "=== DATA FLOW ===" 75 | echo "$RESPONSE" | jq -r '.dataFlow | "PVE Guests: \(.pve.guests_count) (\(.pve.vms_count) VMs, \(.pve.containers_count) Containers)\nPBS Instances: \(.pbs.instances_count)\nTotal Backups: \(.pbs.backups_total)"' 76 | echo "" 77 | 78 | # Show PBS backup matching details 79 | echo "PBS Backup Matching:" 80 | echo "$RESPONSE" | jq -r '.dataFlow.pbs.backup_matching[] | " Instance \(.index): \(.backups_count) backups, \(.matching_backups) matching current guests"' 81 | echo "" 82 | 83 | echo "=== RECOMMENDATIONS ===" 84 | RECOMMENDATIONS=$(echo "$RESPONSE" | jq -r '.recommendations[] | "[\(.severity | ascii_upcase)] \(.category): \(.message)"') 85 | if [ -z "$RECOMMENDATIONS" ]; then 86 | echo "No issues found - everything looks good!" 87 | else 88 | echo "$RECOMMENDATIONS" 89 | fi 90 | echo "" 91 | 92 | echo "=== SHARING THIS REPORT ===" 93 | echo "To share this diagnostic report:" 94 | echo "1. Open the file: $REPORT_FILE" 95 | echo "2. Remove any sensitive information (tokens, IPs if needed)" 96 | echo "3. Share the file content when reporting issues" 97 | 98 | else 99 | echo "Note: Install 'jq' for formatted output (apt-get install jq or brew install jq)" 100 | echo "" 101 | echo "Raw diagnostic data saved to: $REPORT_FILE" 102 | echo "Please share this file when reporting issues (after removing sensitive data)" 103 | fi 104 | } 105 | 106 | # Main execution 107 | if check_pulse_running; then 108 | run_diagnostics 109 | fi 110 | 111 | echo "" 112 | echo "================================" 113 | echo "End of Diagnostic Report" 114 | echo "================================" -------------------------------------------------------------------------------- /scripts/fix-css.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Pulse CSS Fix Script 3 | # This script fixes CSS issues in broken Pulse installations 4 | # where the frontend shows no styling due to CSS MIME type errors 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | print_info() { 16 | echo -e "${BLUE}[INFO]${NC} $1" 17 | } 18 | 19 | print_success() { 20 | echo -e "${GREEN}[SUCCESS]${NC} $1" 21 | } 22 | 23 | print_warning() { 24 | echo -e "${YELLOW}[WARNING]${NC} $1" 25 | } 26 | 27 | print_error() { 28 | echo -e "${RED}[ERROR]${NC} $1" 29 | } 30 | 31 | # Default installation path 32 | PULSE_DIR="/opt/pulse" 33 | 34 | print_info "Pulse CSS Fix Script" 35 | print_info "This script fixes CSS issues where the frontend has no styling" 36 | echo "" 37 | 38 | # Check if Pulse directory exists 39 | if [ ! -d "$PULSE_DIR" ]; then 40 | print_error "Pulse installation directory not found at $PULSE_DIR" 41 | print_info "Please specify the correct Pulse installation path:" 42 | read -p "Enter Pulse installation path: " PULSE_DIR 43 | 44 | if [ ! -d "$PULSE_DIR" ]; then 45 | print_error "Directory $PULSE_DIR does not exist. Exiting." 46 | exit 1 47 | fi 48 | fi 49 | 50 | print_info "Using Pulse installation at: $PULSE_DIR" 51 | 52 | # Check if this is a Pulse installation 53 | if [ ! -f "$PULSE_DIR/package.json" ] || [ ! -f "$PULSE_DIR/server/index.js" ]; then 54 | print_error "This doesn't appear to be a valid Pulse installation" 55 | print_error "Missing package.json or server/index.js" 56 | exit 1 57 | fi 58 | 59 | # Change to Pulse directory 60 | cd "$PULSE_DIR" || { 61 | print_error "Failed to change to $PULSE_DIR" 62 | exit 1 63 | } 64 | 65 | print_info "Checking current CSS status..." 66 | 67 | # Check if output.css exists and has content 68 | if [ ! -f "src/public/output.css" ]; then 69 | print_warning "output.css file is missing" 70 | CSS_MISSING=1 71 | elif [ ! -s "src/public/output.css" ]; then 72 | print_warning "output.css file is empty" 73 | CSS_EMPTY=1 74 | else 75 | # Check if CSS contains actual CSS content (not HTML error page) 76 | if head -1 "src/public/output.css" | grep -q "/dev/null 2>&1; then 104 | # Try to rebuild 105 | if npm run build:css >/dev/null 2>&1; then 106 | print_success "CSS rebuilt successfully using npm run build:css" 107 | 108 | # Verify the rebuild worked 109 | if [ -f "src/public/output.css" ] && [ -s "src/public/output.css" ]; then 110 | if ! head -1 "src/public/output.css" | grep -q "/dev/null 2>&1; then 135 | print_info "Downloading CSS from latest Pulse release..." 136 | 137 | # Get latest release info 138 | LATEST_RELEASE=$(curl -s https://api.github.com/repos/rcourtman/Pulse/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4) 139 | 140 | if [ -n "$LATEST_RELEASE" ]; then 141 | print_info "Latest release: $LATEST_RELEASE" 142 | 143 | # Download and extract just the CSS file 144 | TEMP_DIR=$(mktemp -d) 145 | cd "$TEMP_DIR" 146 | 147 | # Download tarball 148 | if curl -sL "https://github.com/rcourtman/Pulse/releases/download/$LATEST_RELEASE/pulse-${LATEST_RELEASE#v}.tar.gz" -o pulse.tar.gz; then 149 | # Extract just the CSS file 150 | if tar -xzf pulse.tar.gz --wildcards "*/src/public/output.css" 2>/dev/null; then 151 | # Find and copy the CSS file 152 | CSS_FILE=$(find . -name "output.css" -path "*/src/public/*" | head -1) 153 | if [ -n "$CSS_FILE" ] && [ -f "$CSS_FILE" ]; then 154 | cp "$CSS_FILE" "$PULSE_DIR/src/public/output.css" 155 | print_success "CSS downloaded and installed from release $LATEST_RELEASE" 156 | 157 | # Clean up temp directory 158 | cd "$PULSE_DIR" 159 | rm -rf "$TEMP_DIR" 160 | 161 | print_success "CSS fix completed successfully!" 162 | print_info "Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to see the changes" 163 | exit 0 164 | else 165 | print_error "Could not find CSS file in downloaded release" 166 | fi 167 | else 168 | print_error "Failed to extract CSS from downloaded release" 169 | fi 170 | else 171 | print_error "Failed to download release tarball" 172 | fi 173 | 174 | # Clean up temp directory 175 | cd "$PULSE_DIR" 176 | rm -rf "$TEMP_DIR" 177 | else 178 | print_error "Could not determine latest release version" 179 | fi 180 | else 181 | print_warning "curl command not found, cannot download CSS" 182 | fi 183 | 184 | # Method 3: Create minimal CSS as last resort 185 | print_warning "All automated fixes failed. Creating minimal CSS as last resort..." 186 | 187 | # Create a basic CSS file that will at least make the page usable 188 | cat > "src/public/output.css" << 'EOF' 189 | /* Minimal CSS for Pulse - Emergency Fix */ 190 | * { box-sizing: border-box; } 191 | body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; } 192 | .container { max-width: 1200px; margin: 0 auto; } 193 | .card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } 194 | .btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; } 195 | .btn-primary { background: #3b82f6; color: white; } 196 | .text-red-600 { color: #dc2626; } 197 | .text-green-600 { color: #16a34a; } 198 | .text-yellow-600 { color: #ca8a04; } 199 | .hidden { display: none; } 200 | .flex { display: flex; } 201 | .grid { display: grid; } 202 | .gap-4 { gap: 1rem; } 203 | EOF 204 | 205 | print_warning "Created minimal emergency CSS" 206 | print_warning "This provides basic styling but is not the full Pulse theme" 207 | print_info "Consider running the Pulse installer again or manually rebuilding CSS" 208 | 209 | print_info "Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to see the changes" 210 | print_info "CSS fix script completed" -------------------------------------------------------------------------------- /scripts/test-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test script for update mechanism 4 | # This script enables test mode for the update system 5 | 6 | echo "🧪 Pulse Update Test Mode" 7 | echo "========================" 8 | echo "" 9 | echo "This script will start Pulse in update test mode." 10 | echo "In this mode, the application will simulate an available update" 11 | echo "without requiring a real GitHub release." 12 | echo "" 13 | echo "Press Ctrl+C to stop the server." 14 | echo "" 15 | 16 | # Export test environment variables 17 | export UPDATE_TEST_MODE=true 18 | export UPDATE_TEST_VERSION=99.99.99 19 | 20 | # Optional: Allow custom test version 21 | if [ "$1" ]; then 22 | export UPDATE_TEST_VERSION=$1 23 | echo "Using test version: $UPDATE_TEST_VERSION" 24 | else 25 | echo "Using default test version: 99.99.99" 26 | fi 27 | 28 | echo "" 29 | echo "Starting server with update test mode enabled..." 30 | echo "" 31 | 32 | # Start the server 33 | cd "$(dirname "$0")/.." || exit 34 | npm run dev:server -------------------------------------------------------------------------------- /server/apiClients.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const https = require('https'); 3 | const axiosRetry = require('axios-retry').default; 4 | 5 | /** 6 | * Creates a request interceptor for PVE API authentication. 7 | * @param {Object} endpoint - The PVE endpoint configuration. 8 | * @returns {Function} - An Axios request interceptor function. 9 | */ 10 | function createPveAuthInterceptor(endpoint) { 11 | return config => { 12 | if (endpoint.tokenId && endpoint.tokenSecret) { 13 | config.headers.Authorization = `PVEAPIToken=${endpoint.tokenId}=${endpoint.tokenSecret}`; 14 | } else { 15 | // Error condition for missing credentials 16 | console.error(`ERROR: Endpoint ${endpoint.name} is missing required API token credentials.`); 17 | } 18 | return config; 19 | }; 20 | } 21 | 22 | /** 23 | * Logs a warning and calculates exponential delay for PVE retries. 24 | * @param {string} endpointName - The name of the PVE endpoint. 25 | * @param {number} retryCount - The current retry attempt number. 26 | * @param {Error} error - The error that caused the retry. 27 | * @returns {number} - The delay in milliseconds. 28 | */ 29 | function pveRetryDelayLogger(endpointName, retryCount, error) { 30 | console.warn(`Retrying PVE API request for ${endpointName} (attempt ${retryCount}) due to error: ${error.message}`); 31 | return axiosRetry.exponentialDelay(retryCount); 32 | } 33 | 34 | /** 35 | * Checks if an error warrants retrying a PVE API call. 36 | * @param {Error} error - The error object. 37 | * @returns {boolean} - True if the request should be retried, false otherwise. 38 | */ 39 | function pveRetryConditionChecker(error) { 40 | return ( 41 | axiosRetry.isNetworkError(error) || 42 | axiosRetry.isRetryableError(error) || 43 | error.response?.status === 596 // Specific PVE status code 44 | ); 45 | } 46 | 47 | /** 48 | * Initializes Axios clients for Proxmox VE endpoints. 49 | * @param {Array} endpoints - Array of PVE endpoint configuration objects. 50 | * @returns {Object} - Object containing initialized PVE API clients keyed by endpoint ID. 51 | */ 52 | function initializePveClients(endpoints) { 53 | const apiClients = {}; 54 | console.log(`INFO: Initializing API clients for ${endpoints.length} Proxmox VE endpoints...`); 55 | 56 | endpoints.forEach(endpoint => { 57 | if (!endpoint.enabled) { 58 | console.log(`INFO: Skipping disabled PVE endpoint: ${endpoint.name} (${endpoint.host})`); 59 | return; // Skip disabled endpoints 60 | } 61 | 62 | const baseURL = endpoint.host.includes('://') 63 | ? `${endpoint.host}/api2/json` 64 | : `https://${endpoint.host}:${endpoint.port}/api2/json`; 65 | 66 | const authInterceptor = createPveAuthInterceptor(endpoint); 67 | const retryConfig = { 68 | retryDelayLogger: pveRetryDelayLogger.bind(null, endpoint.name), 69 | retryConditionChecker: pveRetryConditionChecker, 70 | }; 71 | 72 | const apiClient = createApiClientInstance(baseURL, endpoint.allowSelfSignedCerts, authInterceptor, retryConfig); 73 | 74 | apiClients[endpoint.id] = { client: apiClient, config: endpoint }; 75 | console.log(`INFO: Initialized PVE API client for endpoint: ${endpoint.name} (${endpoint.host})`); 76 | }); 77 | 78 | return apiClients; 79 | } 80 | 81 | // Generic function to create an Axios API client instance 82 | function createApiClientInstance(baseURL, allowSelfSignedCerts, authInterceptor, retryConfig) { 83 | const apiClient = axios.create({ 84 | baseURL: baseURL, 85 | timeout: 30000, // 30 second timeout 86 | httpsAgent: new https.Agent({ 87 | rejectUnauthorized: !allowSelfSignedCerts 88 | }), 89 | headers: { 90 | 'Content-Type': 'application/json' 91 | } 92 | }); 93 | 94 | if (authInterceptor) { 95 | apiClient.interceptors.request.use(authInterceptor); 96 | } 97 | 98 | if (retryConfig) { 99 | axiosRetry(apiClient, { 100 | retries: retryConfig.retries || 3, 101 | retryDelay: retryConfig.retryDelayLogger, 102 | retryCondition: retryConfig.retryConditionChecker, 103 | }); 104 | } 105 | return apiClient; 106 | } 107 | 108 | /** 109 | * Logs a warning and calculates exponential delay for PBS retries. 110 | * @param {string} configName - The name of the PBS configuration. 111 | * @param {number} retryCount - The current retry attempt number. 112 | * @param {Error} error - The error that caused the retry. 113 | * @returns {number} - The delay in milliseconds. 114 | */ 115 | function pbsRetryDelayLogger(configName, retryCount, error) { 116 | console.warn(`Retrying PBS API request for ${configName} (Token Auth - attempt ${retryCount}) due to error: ${error.message}`); 117 | return axiosRetry.exponentialDelay(retryCount); 118 | } 119 | 120 | /** 121 | * Checks if an error warrants retrying a PBS API call. 122 | * @param {Error} error - The error object. 123 | * @returns {boolean} - True if the request should be retried, false otherwise. 124 | */ 125 | function pbsRetryConditionChecker(error) { 126 | return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error); 127 | } 128 | 129 | /** 130 | * Creates a request interceptor for PBS API authentication (Token Auth). 131 | * @param {Object} config - The PBS configuration object. 132 | * @returns {Function} - An Axios request interceptor function. 133 | */ 134 | function createPbsAuthInterceptor(config) { 135 | return reqConfig => { 136 | // Assumes config.tokenId and config.tokenSecret exist (checked during config load perhaps?) 137 | reqConfig.headers.Authorization = `PBSAPIToken=${config.tokenId}:${config.tokenSecret}`; 138 | return reqConfig; 139 | }; 140 | } 141 | 142 | /** 143 | * Initializes Axios clients for Proxmox Backup Server instances. 144 | * @param {Array} pbsConfigs - Array of PBS configuration objects. 145 | * @returns {Promise} - Promise resolving to an object containing initialized PBS API clients keyed by config ID. 146 | */ 147 | async function initializePbsClients(pbsConfigs) { 148 | const pbsApiClients = {}; 149 | if (pbsConfigs.length === 0) { 150 | console.log("INFO: No PBS instances configured, skipping PBS client initialization."); 151 | return pbsApiClients; 152 | } 153 | 154 | console.log(`INFO: Initializing API clients for ${pbsConfigs.length} PBS instances...`); 155 | const initPromises = pbsConfigs.map(async (config) => { 156 | let clientData = null; 157 | try { 158 | if (config.authMethod === 'token') { 159 | const pbsBaseURL = config.host.includes('://') 160 | ? `${config.host}/api2/json` 161 | : `https://${config.host}:${config.port}/api2/json`; 162 | 163 | const authInterceptor = createPbsAuthInterceptor(config); 164 | const retryConfig = { 165 | retryDelayLogger: pbsRetryDelayLogger.bind(null, config.name), 166 | retryConditionChecker: pbsRetryConditionChecker, 167 | }; 168 | 169 | const pbsAxiosInstance = createApiClientInstance(pbsBaseURL, config.allowSelfSignedCerts, authInterceptor, retryConfig); 170 | 171 | clientData = { client: pbsAxiosInstance, config: config }; 172 | console.log(`INFO: [PBS Init] Successfully initialized client for instance '${config.name}' (Token Auth)`); 173 | } else { 174 | console.error(`ERROR: Unexpected authMethod '${config.authMethod}' found during PBS client initialization for: ${config.name}`); 175 | } 176 | 177 | if (clientData) { 178 | pbsApiClients[config.id] = clientData; 179 | } 180 | } catch (error) { 181 | console.error(`ERROR: Unhandled exception during PBS client initialization for ${config.name}: ${error.message}`, error.stack); 182 | } 183 | // We don't return clientData here, we modify pbsApiClients directly 184 | }); 185 | 186 | await Promise.allSettled(initPromises); 187 | console.log(`INFO: [PBS Init] Finished initialization. ${Object.keys(pbsApiClients).length} / ${pbsConfigs.length} PBS clients initialized successfully.`); 188 | return pbsApiClients; 189 | } 190 | 191 | /** 192 | * Initializes all Proxmox VE and PBS API clients. 193 | * @param {Array} endpoints - Array of PVE endpoint configuration objects. 194 | * @param {Array} pbsConfigs - Array of PBS configuration objects. 195 | * @returns {Promise} - Promise resolving to an object containing { apiClients, pbsApiClients }. 196 | */ 197 | async function initializeApiClients(endpoints, pbsConfigs) { 198 | const apiClients = initializePveClients(endpoints); 199 | const pbsApiClients = await initializePbsClients(pbsConfigs); // Wait for PBS clients to initialize 200 | return { apiClients, pbsApiClients }; 201 | } 202 | 203 | // Export the new helper functions for potential direct testing 204 | module.exports = { 205 | initializeApiClients, 206 | createPveAuthInterceptor, 207 | createPbsAuthInterceptor, 208 | pveRetryDelayLogger, 209 | pveRetryConditionChecker, 210 | pbsRetryDelayLogger, 211 | pbsRetryConditionChecker, 212 | createApiClientInstance, 213 | }; 214 | -------------------------------------------------------------------------------- /server/configLoader.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | 3 | // Align placeholder values with install script and .env.example 4 | const placeholderValues = [ 5 | // Hostname parts - case-insensitive matching might be better if OS env vars differ. 6 | // For now, direct case-sensitive include check. 7 | 'your-proxmox-ip-or-hostname', 8 | 'proxmox_host', // Substring for https://proxmox_host:8006 or similar 9 | 'YOUR_PBS_IP_OR_HOSTNAME', // For PBS host 10 | 11 | // Token ID parts - these are more specific to example/guidance values 12 | 'user@pam!your-token-name', // Matches common PVE example format 13 | 'user@pbs!your-token-name', // Matches common PBS example format 14 | 'your-api-token-id', // Generic part often seen in examples 15 | 'user@pam!tokenid', // From original install script comment 16 | 'user@pbs!tokenid', // PBS variant of install script comment 17 | 18 | // Secret parts 19 | 'your-token-secret-uuid', // Common PVE secret example 20 | 'your-pbs-token-secret-uuid', // Common PBS secret example 21 | 'YOUR_API_SECRET_HERE', // From original install script comment 22 | 'secret-uuid', // Specific value used in config.test.js 23 | 'your-api-token-uuid', // Specific value used in config.test.js for PROXMOX_TOKEN_SECRET 24 | 'your-port' // Specific value used in config.test.js for PROXMOX_PORT 25 | ]; 26 | 27 | // Error class for configuration issues 28 | class ConfigurationError extends Error { 29 | constructor(message) { 30 | super(message); 31 | this.name = 'ConfigurationError'; 32 | } 33 | } 34 | 35 | // Function to load PBS configuration 36 | function loadPbsConfig(index = null) { 37 | const suffix = index ? `_${index}` : ''; 38 | const hostVar = `PBS_HOST${suffix}`; 39 | const tokenIdVar = `PBS_TOKEN_ID${suffix}`; 40 | const tokenSecretVar = `PBS_TOKEN_SECRET${suffix}`; 41 | const nodeNameVar = `PBS_NODE_NAME${suffix}`; 42 | const portVar = `PBS_PORT${suffix}`; 43 | const selfSignedVar = `PBS_ALLOW_SELF_SIGNED_CERTS${suffix}`; 44 | 45 | const pbsHostUrl = process.env[hostVar]; 46 | if (!pbsHostUrl) { 47 | return false; // No more PBS configs if PBS_HOST is missing 48 | } 49 | 50 | let pbsHostname = pbsHostUrl; 51 | try { 52 | const parsedUrl = new URL(pbsHostUrl); 53 | pbsHostname = parsedUrl.hostname; 54 | } catch (e) { 55 | // console.warn(`WARN: Could not parse PBS_HOST URL "${pbsHostUrl}". Using full value as fallback name.`); 56 | } 57 | 58 | const pbsTokenId = process.env[tokenIdVar]; 59 | const pbsTokenSecret = process.env[tokenSecretVar]; 60 | 61 | let config = null; 62 | let idPrefix = index ? `pbs_endpoint_${index}` : 'pbs_primary'; 63 | 64 | if (pbsTokenId && pbsTokenSecret) { 65 | const pbsPlaceholders = placeholderValues.filter(p => 66 | pbsHostUrl.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) 67 | ); 68 | if (pbsPlaceholders.length > 0) { 69 | console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (Token). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`); 70 | } else { 71 | config = { 72 | id: `${idPrefix}_token`, 73 | authMethod: 'token', 74 | name: process.env[nodeNameVar] || pbsHostname, 75 | host: pbsHostUrl, 76 | port: process.env[portVar] || '8007', 77 | tokenId: pbsTokenId, 78 | tokenSecret: pbsTokenSecret, 79 | nodeName: process.env[nodeNameVar], // Keep nodeName field 80 | allowSelfSignedCerts: process.env[selfSignedVar] !== 'false', 81 | enabled: true 82 | }; 83 | console.log(`INFO: Found PBS configuration ${index || 'primary'} with ID: ${config.id}, name: ${config.name}, host: ${config.host}`); 84 | } 85 | } else { 86 | console.warn(`WARN: Partial PBS configuration found for ${hostVar}. Please set (${tokenIdVar} + ${tokenSecretVar}) along with ${hostVar}.`); 87 | } 88 | 89 | if (config) { 90 | return { found: true, config: config }; // Return config if found 91 | } 92 | // Return found:true if host was set, but config was invalid/partial 93 | return { found: !!pbsHostUrl, config: null }; 94 | } 95 | 96 | 97 | // Main function to load all configurations 98 | function loadConfiguration() { 99 | // Only load .env file if not in test environment 100 | if (process.env.NODE_ENV !== 'test') { 101 | const fs = require('fs'); 102 | const path = require('path'); 103 | 104 | const configDir = path.join(__dirname, '../config'); 105 | const configEnvPath = path.join(configDir, '.env'); 106 | const projectEnvPath = path.join(__dirname, '../.env'); 107 | 108 | if (fs.existsSync(configEnvPath)) { 109 | require('dotenv').config({ path: configEnvPath }); 110 | } else { 111 | require('dotenv').config({ path: projectEnvPath }); 112 | } 113 | } 114 | 115 | let isConfigPlaceholder = false; // Add this flag 116 | 117 | // --- Proxmox Primary Endpoint Validation --- 118 | const primaryRequiredEnvVars = [ 119 | 'PROXMOX_HOST', 120 | 'PROXMOX_TOKEN_ID', 121 | 'PROXMOX_TOKEN_SECRET' 122 | ]; 123 | let missingVars = []; 124 | let placeholderVars = []; 125 | 126 | primaryRequiredEnvVars.forEach(varName => { 127 | const value = process.env[varName]; 128 | if (!value) { 129 | missingVars.push(varName); 130 | } else if (placeholderValues.some(placeholder => value.includes(placeholder) || placeholder.includes(value))) { 131 | placeholderVars.push(varName); 132 | } 133 | }); 134 | 135 | // Throw error only if required vars are MISSING 136 | if (missingVars.length > 0) { 137 | console.warn('--- Configuration Warning ---'); 138 | console.warn(`Missing required environment variables: ${missingVars.join(', ')}.`); 139 | console.warn('Pulse will start in setup mode. Please configure via the web interface.'); 140 | 141 | // Return minimal configuration to allow server to start 142 | return { 143 | endpoints: [], 144 | pbsConfigs: [], 145 | isConfigPlaceholder: true 146 | }; 147 | } 148 | 149 | // Set the flag if placeholders were detected (but don't throw error) 150 | if (placeholderVars.length > 0) { 151 | isConfigPlaceholder = true; 152 | // Ensure token ID placeholder is included if missing 153 | if (process.env.PROXMOX_TOKEN_ID && !placeholderVars.includes('PROXMOX_TOKEN_ID')) { 154 | const hostIdx = placeholderVars.indexOf('PROXMOX_HOST'); 155 | if (hostIdx !== -1) placeholderVars.splice(hostIdx + 1, 0, 'PROXMOX_TOKEN_ID'); 156 | else placeholderVars.push('PROXMOX_TOKEN_ID'); 157 | } 158 | console.warn(`WARN: Primary Proxmox environment variables seem to contain placeholder values: ${placeholderVars.join(', ')}. Pulse may not function correctly until configured.`); 159 | } 160 | 161 | // --- Load All Proxmox Endpoint Configurations --- 162 | const endpoints = []; 163 | 164 | function createProxmoxEndpointConfig(idPrefix, index, hostEnv, portEnv, tokenIdEnv, tokenSecretEnv, enabledEnv, selfSignedEnv, nodeNameEnv) { 165 | const host = process.env[hostEnv]; 166 | const tokenId = process.env[tokenIdEnv]; 167 | const tokenSecret = process.env[tokenSecretEnv]; 168 | const nodeName = process.env[nodeNameEnv]; 169 | 170 | // Basic validation for additional endpoints (primary is validated earlier) 171 | if (index !== null && (!tokenId || !tokenSecret)) { 172 | // console.warn(`WARN: Skipping endpoint ${index || idPrefix} (Host: ${host}). Missing token ID or secret.`); 173 | return null; 174 | } 175 | if (index !== null && placeholderValues.some(p => host.includes(p) || tokenId.includes(p) || tokenSecret.includes(p))) { 176 | // console.warn(`WARN: Skipping endpoint ${index || idPrefix} (Host: ${host}). Environment variables seem to contain placeholder values.`); 177 | return null; 178 | } 179 | 180 | return { 181 | id: index ? `${idPrefix}_${index}` : idPrefix, 182 | name: nodeName || null, // Only use explicitly configured names 183 | host: host, 184 | port: process.env[portEnv] || '8006', 185 | tokenId: tokenId, 186 | tokenSecret: tokenSecret, 187 | enabled: process.env[enabledEnv] !== 'false', 188 | allowSelfSignedCerts: process.env[selfSignedEnv] !== 'false', 189 | }; 190 | } 191 | 192 | // Load primary endpoint (index null for helper) 193 | const primaryEndpoint = createProxmoxEndpointConfig( 194 | 'primary', 195 | null, // No index for primary 196 | 'PROXMOX_HOST', 197 | 'PROXMOX_PORT', 198 | 'PROXMOX_TOKEN_ID', 199 | 'PROXMOX_TOKEN_SECRET', 200 | 'PROXMOX_ENABLED', 201 | 'PROXMOX_ALLOW_SELF_SIGNED_CERTS', 202 | 'PROXMOX_NODE_NAME' 203 | ); 204 | if (primaryEndpoint) { // Should always exist due to earlier checks, but good practice 205 | endpoints.push(primaryEndpoint); 206 | } 207 | 208 | // Load additional Proxmox endpoints 209 | // Check all environment variables for PROXMOX_HOST_N pattern to handle non-sequential numbering 210 | const proxmoxHostKeys = Object.keys(process.env) 211 | .filter(key => key.match(/^PROXMOX_HOST_\d+$/)) 212 | .map(key => { 213 | const match = key.match(/^PROXMOX_HOST_(\d+)$/); 214 | return match ? parseInt(match[1]) : null; 215 | }) 216 | .filter(num => num !== null) 217 | .sort((a, b) => a - b); 218 | 219 | for (const i of proxmoxHostKeys) { 220 | const additionalEndpoint = createProxmoxEndpointConfig( 221 | 'endpoint', 222 | i, 223 | `PROXMOX_HOST_${i}`, 224 | `PROXMOX_PORT_${i}`, 225 | `PROXMOX_TOKEN_ID_${i}`, 226 | `PROXMOX_TOKEN_SECRET_${i}`, 227 | `PROXMOX_ENABLED_${i}`, 228 | `PROXMOX_ALLOW_SELF_SIGNED_CERTS_${i}`, 229 | `PROXMOX_NODE_NAME_${i}` 230 | ); 231 | if (additionalEndpoint) { 232 | endpoints.push(additionalEndpoint); 233 | } 234 | } 235 | 236 | if (endpoints.length > 1) { 237 | // console.log(`INFO: Loaded configuration for ${endpoints.length} Proxmox endpoints.`); 238 | } 239 | 240 | // --- Load All PBS Configurations --- 241 | const pbsConfigs = []; 242 | // Load primary PBS config 243 | const primaryPbsResult = loadPbsConfig(); 244 | /* istanbul ignore else */ // Ignore else path - tested by 'should not add primary PBS config if host is set but tokens are missing' 245 | if (primaryPbsResult.config) { 246 | pbsConfigs.push(primaryPbsResult.config); 247 | } 248 | 249 | // Load additional PBS configs 250 | // Check all environment variables for PBS_HOST_N pattern to handle non-sequential numbering 251 | const pbsHostKeys = Object.keys(process.env) 252 | .filter(key => key.match(/^PBS_HOST_\d+$/)) 253 | .map(key => { 254 | const match = key.match(/^PBS_HOST_(\d+)$/); 255 | return match ? parseInt(match[1]) : null; 256 | }) 257 | .filter(num => num !== null) 258 | .sort((a, b) => a - b); 259 | 260 | for (const pbsIndex of pbsHostKeys) { 261 | const pbsResult = loadPbsConfig(pbsIndex); 262 | if (pbsResult.config) { 263 | pbsConfigs.push(pbsResult.config); 264 | } 265 | } 266 | 267 | if (pbsConfigs.length > 0) { 268 | // console.log(`INFO: Loaded configuration for ${pbsConfigs.length} PBS instances.`); 269 | } else { 270 | // console.log("INFO: No PBS instances configured."); 271 | } 272 | 273 | // --- Final Validation --- 274 | const enabledEndpoints = endpoints.filter(e => e.enabled); 275 | if (enabledEndpoints.length === 0 && pbsConfigs.length === 0) { 276 | // Throw error instead of exiting 277 | throw new ConfigurationError('\n--- Configuration Error ---\nNo enabled Proxmox VE or PBS endpoints could be configured. Please check your .env file and environment variables.\n'); 278 | } 279 | 280 | // console.log('INFO: Configuration loaded successfully.'); 281 | // Return the flag along with endpoints and pbsConfigs 282 | return { endpoints, pbsConfigs, isConfigPlaceholder }; 283 | } 284 | 285 | module.exports = { loadConfiguration, ConfigurationError }; // Export the function and error class 286 | -------------------------------------------------------------------------------- /server/customThresholds.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const path = require('path'); 3 | 4 | /** 5 | * Custom Threshold Manager 6 | * Handles per-VM/LXC threshold configurations 7 | */ 8 | class CustomThresholdManager { 9 | constructor() { 10 | this.configPath = path.join(__dirname, '../data/custom-thresholds.json'); 11 | this.cache = new Map(); // endpointId:nodeId:vmid -> thresholds 12 | this.initialized = false; 13 | } 14 | 15 | /** 16 | * Initialize the threshold manager 17 | */ 18 | async init() { 19 | try { 20 | await this.loadThresholds(); 21 | this.initialized = true; 22 | console.log('[CustomThresholds] Initialized successfully'); 23 | } catch (error) { 24 | console.error('[CustomThresholds] Initialization failed:', error); 25 | } 26 | } 27 | 28 | /** 29 | * Load thresholds from storage 30 | */ 31 | async loadThresholds() { 32 | try { 33 | // Ensure data directory exists 34 | await fs.mkdir(path.dirname(this.configPath), { recursive: true }); 35 | 36 | const data = await fs.readFile(this.configPath, 'utf8'); 37 | const thresholds = JSON.parse(data); 38 | 39 | // Clear cache and rebuild 40 | this.cache.clear(); 41 | 42 | Object.entries(thresholds).forEach(([key, config]) => { 43 | this.cache.set(key, config); 44 | }); 45 | 46 | console.log(`[CustomThresholds] Loaded ${this.cache.size} threshold configurations`); 47 | } catch (error) { 48 | if (error.code === 'ENOENT') { 49 | // File doesn't exist yet, create empty structure 50 | await this.saveThresholds(); 51 | console.log('[CustomThresholds] Created new threshold configuration file'); 52 | } else { 53 | console.error('[CustomThresholds] Error loading thresholds:', error); 54 | throw error; 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Save thresholds to storage 61 | */ 62 | async saveThresholds() { 63 | try { 64 | const data = Object.fromEntries(this.cache); 65 | await fs.writeFile(this.configPath, JSON.stringify(data, null, 2), 'utf8'); 66 | console.log('[CustomThresholds] Saved threshold configurations'); 67 | } catch (error) { 68 | console.error('[CustomThresholds] Error saving thresholds:', error); 69 | throw error; 70 | } 71 | } 72 | 73 | /** 74 | * Generate cache key for threshold lookup 75 | * Note: We use endpointId:vmid (without nodeId) to support VM migration within clusters 76 | */ 77 | generateKey(endpointId, nodeId, vmid) { 78 | return `${endpointId}:${vmid}`; 79 | } 80 | 81 | /** 82 | * Get thresholds for a specific VM/LXC 83 | * Returns custom thresholds if configured, otherwise returns null 84 | */ 85 | getThresholds(endpointId, nodeId, vmid) { 86 | const key = this.generateKey(endpointId, nodeId, vmid); 87 | return this.cache.get(key) || null; 88 | } 89 | 90 | /** 91 | * Set custom thresholds for a VM/LXC 92 | */ 93 | async setThresholds(endpointId, nodeId, vmid, thresholds) { 94 | try { 95 | const key = this.generateKey(endpointId, nodeId, vmid); 96 | 97 | // Validate threshold structure 98 | const validatedThresholds = this.validateThresholds(thresholds); 99 | 100 | this.cache.set(key, { 101 | endpointId, 102 | nodeId, 103 | vmid, 104 | thresholds: validatedThresholds, 105 | enabled: true, 106 | createdAt: new Date().toISOString(), 107 | updatedAt: new Date().toISOString() 108 | }); 109 | 110 | await this.saveThresholds(); 111 | return true; 112 | } catch (error) { 113 | console.error('[CustomThresholds] Error setting thresholds:', error); 114 | throw error; 115 | } 116 | } 117 | 118 | /** 119 | * Remove custom thresholds for a VM/LXC 120 | */ 121 | async removeThresholds(endpointId, nodeId, vmid) { 122 | try { 123 | const key = this.generateKey(endpointId, nodeId, vmid); 124 | 125 | if (this.cache.has(key)) { 126 | this.cache.delete(key); 127 | await this.saveThresholds(); 128 | return true; 129 | } 130 | return false; 131 | } catch (error) { 132 | console.error('[CustomThresholds] Error removing thresholds:', error); 133 | throw error; 134 | } 135 | } 136 | 137 | /** 138 | * Get all custom threshold configurations 139 | */ 140 | getAllThresholds() { 141 | return Array.from(this.cache.values()); 142 | } 143 | 144 | /** 145 | * Get thresholds for a specific endpoint 146 | */ 147 | getThresholdsByEndpoint(endpointId) { 148 | return Array.from(this.cache.values()).filter(config => config.endpointId === endpointId); 149 | } 150 | 151 | /** 152 | * Validate threshold configuration 153 | */ 154 | validateThresholds(thresholds) { 155 | const validated = {}; 156 | 157 | // CPU thresholds 158 | if (thresholds.cpu) { 159 | validated.cpu = { 160 | warning: this.validateThresholdValue(thresholds.cpu.warning, 'cpu.warning'), 161 | critical: this.validateThresholdValue(thresholds.cpu.critical, 'cpu.critical') 162 | }; 163 | 164 | // Ensure critical > warning 165 | if (validated.cpu.critical <= validated.cpu.warning) { 166 | throw new Error('CPU critical threshold must be greater than warning threshold'); 167 | } 168 | } 169 | 170 | // Memory thresholds 171 | if (thresholds.memory) { 172 | validated.memory = { 173 | warning: this.validateThresholdValue(thresholds.memory.warning, 'memory.warning'), 174 | critical: this.validateThresholdValue(thresholds.memory.critical, 'memory.critical') 175 | }; 176 | 177 | // Ensure critical > warning 178 | if (validated.memory.critical <= validated.memory.warning) { 179 | throw new Error('Memory critical threshold must be greater than warning threshold'); 180 | } 181 | } 182 | 183 | // Disk thresholds 184 | if (thresholds.disk) { 185 | validated.disk = { 186 | warning: this.validateThresholdValue(thresholds.disk.warning, 'disk.warning'), 187 | critical: this.validateThresholdValue(thresholds.disk.critical, 'disk.critical') 188 | }; 189 | 190 | // Ensure critical > warning 191 | if (validated.disk.critical <= validated.disk.warning) { 192 | throw new Error('Disk critical threshold must be greater than warning threshold'); 193 | } 194 | } 195 | 196 | return validated; 197 | } 198 | 199 | /** 200 | * Validate individual threshold value 201 | */ 202 | validateThresholdValue(value, fieldName) { 203 | if (typeof value !== 'number') { 204 | throw new Error(`${fieldName} must be a number`); 205 | } 206 | 207 | if (value < 0 || value > 100) { 208 | throw new Error(`${fieldName} must be between 0 and 100`); 209 | } 210 | 211 | return value; 212 | } 213 | 214 | /** 215 | * Bulk import thresholds from configuration object 216 | */ 217 | async importThresholds(thresholdConfigs) { 218 | try { 219 | let imported = 0; 220 | 221 | for (const config of thresholdConfigs) { 222 | await this.setThresholds( 223 | config.endpointId, 224 | config.nodeId, 225 | config.vmid, 226 | config.thresholds 227 | ); 228 | imported++; 229 | } 230 | 231 | console.log(`[CustomThresholds] Imported ${imported} threshold configurations`); 232 | return imported; 233 | } catch (error) { 234 | console.error('[CustomThresholds] Error importing thresholds:', error); 235 | throw error; 236 | } 237 | } 238 | 239 | /** 240 | * Export all thresholds for backup/migration 241 | */ 242 | exportThresholds() { 243 | return { 244 | version: '1.0', 245 | exportedAt: new Date().toISOString(), 246 | thresholds: this.getAllThresholds() 247 | }; 248 | } 249 | 250 | /** 251 | * Enable/disable custom thresholds for a VM/LXC 252 | */ 253 | async toggleThresholds(endpointId, nodeId, vmid, enabled) { 254 | try { 255 | const key = this.generateKey(endpointId, nodeId, vmid); 256 | const config = this.cache.get(key); 257 | 258 | if (!config) { 259 | throw new Error('Threshold configuration not found'); 260 | } 261 | 262 | config.enabled = enabled; 263 | config.updatedAt = new Date().toISOString(); 264 | 265 | this.cache.set(key, config); 266 | await this.saveThresholds(); 267 | 268 | return true; 269 | } catch (error) { 270 | console.error('[CustomThresholds] Error toggling thresholds:', error); 271 | throw error; 272 | } 273 | } 274 | 275 | /** 276 | * Get threshold statistics 277 | */ 278 | getStatistics() { 279 | const configs = this.getAllThresholds(); 280 | const enabled = configs.filter(c => c.enabled); 281 | 282 | return { 283 | total: configs.length, 284 | enabled: enabled.length, 285 | disabled: configs.length - enabled.length, 286 | byEndpoint: this.groupByEndpoint(configs), 287 | lastUpdated: configs.length > 0 ? Math.max(...configs.map(c => new Date(c.updatedAt).getTime())) : null 288 | }; 289 | } 290 | 291 | /** 292 | * Group configurations by endpoint 293 | */ 294 | groupByEndpoint(configs) { 295 | const grouped = {}; 296 | 297 | configs.forEach(config => { 298 | if (!grouped[config.endpointId]) { 299 | grouped[config.endpointId] = { 300 | total: 0, 301 | enabled: 0 302 | }; 303 | } 304 | 305 | grouped[config.endpointId].total++; 306 | if (config.enabled) { 307 | grouped[config.endpointId].enabled++; 308 | } 309 | }); 310 | 311 | return grouped; 312 | } 313 | } 314 | 315 | // Create singleton instance 316 | const customThresholdManager = new CustomThresholdManager(); 317 | 318 | module.exports = customThresholdManager; -------------------------------------------------------------------------------- /server/pbsUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Processes a list of raw PBS tasks into structured summaries and recent task lists. 3 | * @param {Array} allTasks - Array of raw task objects from the PBS API. 4 | * @returns {Object} - Object containing structured task data (backupTasks, verificationTasks, etc.). 5 | */ 6 | function processPbsTasks(allTasks) { 7 | // Ensure input is an array; return empty structure if not 8 | if (!Array.isArray(allTasks)) { 9 | console.warn('[PBS Utils] processPbsTasks received non-array input:', allTasks); 10 | return { 11 | backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 12 | verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 13 | syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 14 | pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 15 | aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, 16 | }; 17 | } 18 | 19 | const taskTypeMap = { 20 | backup: 'backup', 21 | verify: 'verify', 22 | verificationjob: 'verify', 23 | verify_group: 'verify', 24 | 'verify-group': 'verify', 25 | verification: 'verify', 26 | sync: 'sync', 27 | syncjob: 'sync', 28 | 'sync-job': 'sync', 29 | garbage_collection: 'pruneGc', 30 | 'garbage-collection': 'pruneGc', 31 | prune: 'pruneGc', 32 | prunejob: 'pruneGc', 33 | 'prune-job': 'pruneGc', 34 | gc: 'pruneGc' 35 | }; 36 | 37 | const taskResults = categorizeAndCountTasks(allTasks, taskTypeMap); 38 | 39 | // Warn if unmapped task types found (useful for production troubleshooting) 40 | const unmappedTypes = new Set(); 41 | allTasks.forEach(task => { 42 | const taskType = task.worker_type || task.type; 43 | if (!taskTypeMap[taskType]) { 44 | unmappedTypes.add(taskType); 45 | } 46 | }); 47 | 48 | if (unmappedTypes.size > 0) { 49 | // console.warn('WARN: [pbsUtils] Unmapped PBS task types found:', Array.from(unmappedTypes)); 50 | } 51 | 52 | const createDetailedTask = (task) => ({ 53 | upid: task.upid, 54 | node: task.node, 55 | type: task.worker_type || task.type, 56 | id: task.worker_id || task.id || task.guest, // Include guest as fallback for ID 57 | status: task.status, 58 | startTime: task.starttime, 59 | endTime: task.endtime, 60 | duration: task.endtime && task.starttime ? task.endtime - task.starttime : null, 61 | user: task.user, 62 | exitCode: task.exitcode, 63 | exitStatus: task.exitstatus, 64 | saved: task.saved || false, 65 | guest: task.guest || task.worker_id, 66 | pbsBackupRun: task.pbsBackupRun, 67 | // Add guest identification fields 68 | guestId: task.guestId, 69 | guestType: task.guestType 70 | }); 71 | 72 | const sortTasksDesc = (a, b) => (b.startTime || 0) - (a.startTime || 0); 73 | 74 | const getRecentTasksList = (taskList, detailedTaskFn, sortFn, count = 20) => { 75 | if (!taskList) return []; 76 | const nowSec = Date.now() / 1000; 77 | const thirtyDays = 30 * 24 * 60 * 60; 78 | const recent = taskList.filter(task => { 79 | // Include tasks without a starttime and tasks within last 30 days 80 | if (task.starttime == null) return true; 81 | return (nowSec - task.starttime) <= thirtyDays; 82 | }); 83 | return recent.map(detailedTaskFn).sort(sortFn).slice(0, count); 84 | }; 85 | 86 | const recentBackupTasks = getRecentTasksList(taskResults.backup.list, createDetailedTask, sortTasksDesc); 87 | const recentVerifyTasks = getRecentTasksList(taskResults.verify.list, createDetailedTask, sortTasksDesc); 88 | const recentSyncTasks = getRecentTasksList(taskResults.sync.list, createDetailedTask, sortTasksDesc); 89 | const recentPruneGcTasks = getRecentTasksList(taskResults.pruneGc.list, createDetailedTask, sortTasksDesc); 90 | 91 | 92 | // Helper function to create the summary object 93 | const createSummary = (category) => ({ 94 | ok: category.ok, 95 | failed: category.failed, 96 | total: category.ok + category.failed, 97 | lastOk: category.lastOk || null, // Use null if 0 98 | lastFailed: category.lastFailed || null // Use null if 0 99 | }); 100 | 101 | return { 102 | backupTasks: { 103 | recentTasks: recentBackupTasks, 104 | summary: createSummary(taskResults.backup) 105 | }, 106 | verificationTasks: { 107 | recentTasks: recentVerifyTasks, 108 | summary: createSummary(taskResults.verify) 109 | }, 110 | syncTasks: { 111 | recentTasks: recentSyncTasks, 112 | summary: createSummary(taskResults.sync) 113 | }, 114 | pruneTasks: { 115 | recentTasks: recentPruneGcTasks, 116 | summary: createSummary(taskResults.pruneGc) 117 | } 118 | }; 119 | } 120 | 121 | function categorizeAndCountTasks(allTasks, taskTypeMap) { 122 | const results = { 123 | backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, 124 | verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, 125 | sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, 126 | pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } 127 | }; 128 | 129 | if (!allTasks || !Array.isArray(allTasks)) { 130 | return results; 131 | } 132 | 133 | allTasks.forEach(task => { 134 | const taskType = task.worker_type || task.type; 135 | const categoryKey = taskTypeMap[taskType]; 136 | 137 | if (categoryKey) { 138 | const category = results[categoryKey]; 139 | category.list.push(task); 140 | 141 | // Fixed status handling - be more specific about what counts as failed 142 | const status = task.status || 'NO_STATUS'; 143 | const isRunning = status.includes('running') || status.includes('queued'); 144 | const isCompleted = status && !isRunning; 145 | 146 | if (isCompleted) { 147 | if (status === 'OK') { 148 | category.ok++; 149 | if (task.endtime && task.endtime > category.lastOk) category.lastOk = task.endtime; 150 | } else { 151 | // Everything that's not OK or running is considered failed 152 | // This includes: WARNING, WARNINGS:..., errors, connection errors, etc. 153 | category.failed++; 154 | if (task.endtime && task.endtime > category.lastFailed) category.lastFailed = task.endtime; 155 | } 156 | } 157 | } 158 | }); 159 | 160 | return results; 161 | } 162 | 163 | module.exports = { processPbsTasks }; 164 | -------------------------------------------------------------------------------- /server/tests/pbsUtils.test.js: -------------------------------------------------------------------------------- 1 | const { processPbsTasks } = require('../pbsUtils'); 2 | 3 | describe('PBS Utils - processPbsTasks', () => { 4 | 5 | test('should return default structure for null input', () => { 6 | const result = processPbsTasks(null); 7 | expect(result).toEqual({ 8 | backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 9 | verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 10 | syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 11 | pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 12 | aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, 13 | }); 14 | }); 15 | 16 | test('should return default structure for empty array input', () => { 17 | const result = processPbsTasks([]); 18 | expect(result).toEqual({ 19 | backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, 20 | verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, 21 | syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, 22 | pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, 23 | }); 24 | }); 25 | 26 | test('should correctly categorize and summarize various task types', () => { 27 | const now = Math.floor(Date.now() / 1000); 28 | const tasks = [ 29 | // Backups 30 | { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 3600, endtime: now - 3500 }, 31 | { upid: 'B2', type: 'backup', status: 'OK', starttime: now - 7200, endtime: now - 7100 }, 32 | { upid: 'B3', worker_type: 'backup', status: 'FAILED', starttime: now - 100, endtime: now - 50 }, 33 | { upid: 'B4', worker_type: 'backup', status: 'ERROR', starttime: now - 40, endtime: now - 20 }, 34 | // Verifications 35 | { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 500, endtime: now - 400 }, 36 | { upid: 'V2', type: 'verificationjob', status: 'WARNING', starttime: now - 600, endtime: now - 550 }, // Treated as failed 37 | // Sync 38 | { upid: 'S1', worker_type: 'sync', status: 'OK', starttime: now - 1000, endtime: now - 900 }, 39 | // Prune/GC 40 | { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 2000, endtime: now - 1900 }, 41 | { upid: 'G1', type: 'garbage_collection', status: 'OK', starttime: now - 2100, endtime: now - 2050 }, 42 | // Unknown/Other 43 | { upid: 'U1', type: 'unknown', status: 'OK', starttime: now - 5000, endtime: now - 4900 }, 44 | // Running task (should not count as OK or Failed) 45 | { upid: 'R1', worker_type: 'backup', status: 'running', starttime: now - 10, endtime: null }, 46 | ]; 47 | 48 | const result = processPbsTasks(tasks); 49 | 50 | // Backup Summary 51 | expect(result.backupTasks.summary.ok).toBe(2); 52 | expect(result.backupTasks.summary.failed).toBe(2); 53 | expect(result.backupTasks.summary.total).toBe(4); 54 | expect(result.backupTasks.summary.lastOk).toBe(now - 3500); 55 | expect(result.backupTasks.summary.lastFailed).toBe(now - 20); 56 | expect(result.backupTasks.recentTasks).toHaveLength(5); 57 | 58 | // Verification Summary 59 | expect(result.verificationTasks.summary.ok).toBe(1); 60 | expect(result.verificationTasks.summary.failed).toBe(1); 61 | expect(result.verificationTasks.summary.total).toBe(2); 62 | expect(result.verificationTasks.summary.lastOk).toBe(now - 400); 63 | expect(result.verificationTasks.summary.lastFailed).toBe(now - 550); 64 | expect(result.verificationTasks.recentTasks).toHaveLength(2); 65 | 66 | // Sync Summary 67 | expect(result.syncTasks.summary.ok).toBe(1); 68 | expect(result.syncTasks.summary.failed).toBe(0); 69 | expect(result.syncTasks.summary.total).toBe(1); 70 | expect(result.syncTasks.summary.lastOk).toBe(now - 900); 71 | expect(result.syncTasks.summary.lastFailed).toBeNull(); 72 | expect(result.syncTasks.recentTasks).toHaveLength(1); 73 | 74 | // Prune/GC Summary 75 | expect(result.pruneTasks.summary.ok).toBe(2); 76 | expect(result.pruneTasks.summary.failed).toBe(0); 77 | expect(result.pruneTasks.summary.total).toBe(2); 78 | expect(result.pruneTasks.summary.lastOk).toBe(now - 1900); // P1 is later than G1 79 | expect(result.pruneTasks.summary.lastFailed).toBeNull(); 80 | expect(result.pruneTasks.recentTasks).toHaveLength(2); 81 | }); 82 | 83 | test('should correctly format recent tasks', () => { 84 | const rawTasks = [ 85 | // Task older than 30 days (should be filtered out) 86 | { 87 | upid: 'B_OLD', 88 | node: 'pbsnode', 89 | type: 'backup', 90 | worker_type: 'backup', 91 | worker_id: 'vm/200', 92 | starttime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000), // 40 days ago 93 | endtime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000) + 60, 94 | status: 'OK', 95 | }, 96 | // Task within last 30 days 97 | { 98 | upid: 'B1', 99 | node: 'pbsnode', 100 | type: 'backup', 101 | worker_type: 'backup', 102 | worker_id: 'vm/100', 103 | starttime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000), // 10 days ago 104 | endtime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) + 50, 105 | status: 'OK', 106 | }, 107 | // Another task within last 30 days 108 | { 109 | upid: 'V1', 110 | node: 'pbsnode', 111 | type: 'verify', 112 | worker_type: 'verify', 113 | worker_id: 'datastore1:group1', // Example worker_id for verify 114 | starttime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000), // 5 days ago 115 | endtime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) + 30, 116 | status: 'WARNING', 117 | exitstatus: 'WARNING: some issues', 118 | } 119 | ]; 120 | 121 | const result = processPbsTasks(rawTasks); 122 | const { recentTasks } = result.backupTasks; // Assuming backupTasks is structured like this 123 | 124 | expect(recentTasks).toHaveLength(1); // Only B1 should be included 125 | expect(recentTasks[0].upid).toBe('B1'); 126 | expect(recentTasks[0].node).toBe('pbsnode'); 127 | expect(recentTasks[0].type).toBe('backup'); 128 | expect(recentTasks[0].status).toBe('OK'); 129 | expect(recentTasks[0].duration).toBe(50); // starttime - endtime 130 | expect(recentTasks[0].guest).toBe('vm/100'); // worker_id 131 | // Add other expected properties based on the actual implementation of processPbsTasks 132 | expect(recentTasks[0].startTime).toBe(rawTasks[1].starttime); // Check original start/end times are mapped 133 | expect(recentTasks[0].endTime).toBe(rawTasks[1].endtime); 134 | expect(recentTasks[0].exitCode).toBeUndefined(); // Assuming no exitcode for OK task 135 | // expect(recentTasks[0]._raw).toBeDefined(); // If _raw is intentionally included 136 | // If _raw is *not* intentionally included, we need to fix processPbsTasks 137 | // For now, let's check for common fields expected in the output: 138 | expect(recentTasks[0]).toHaveProperty('upid'); 139 | expect(recentTasks[0]).toHaveProperty('node'); 140 | expect(recentTasks[0]).toHaveProperty('type'); 141 | expect(recentTasks[0]).toHaveProperty('status'); 142 | expect(recentTasks[0]).toHaveProperty('duration'); 143 | expect(recentTasks[0]).toHaveProperty('guest'); 144 | expect(recentTasks[0]).toHaveProperty('startTime'); 145 | expect(recentTasks[0]).toHaveProperty('endTime'); 146 | // Check that _raw is NOT present if it's not intended 147 | expect(recentTasks[0]._raw).toBeUndefined(); 148 | 149 | const { recentTasks: verifyTasks } = result.verificationTasks; // Check verification tasks 150 | expect(verifyTasks).toHaveLength(1); // Only V1 should be included 151 | expect(verifyTasks[0].upid).toBe('V1'); 152 | expect(verifyTasks[0].status).toBe('WARNING'); 153 | expect(verifyTasks[0].duration).toBe(30); 154 | expect(verifyTasks[0].exitStatus).toBe('WARNING: some issues'); // Assuming exitstatus is mapped 155 | // Check that _raw is NOT present 156 | expect(verifyTasks[0]._raw).toBeUndefined(); 157 | 158 | // Also check summaries if needed by this test 159 | // expect(result.backupTasks.summary).toEqual(...); 160 | // expect(result.verificationTasks.summary).toEqual(...); 161 | 162 | }); 163 | 164 | test('should limit recent tasks to 20 by default', () => { 165 | const now = Math.floor(Date.now() / 1000); 166 | const tasks = []; 167 | for (let i = 0; i < 25; i++) { 168 | tasks.push({ upid: `B${i}`, worker_type: 'backup', status: 'OK', starttime: now - (i * 100), endtime: now - (i * 100) + 50 }); 169 | } 170 | const result = processPbsTasks(tasks); 171 | expect(result.backupTasks.recentTasks).toHaveLength(20); 172 | expect(result.backupTasks.recentTasks[0].upid).toBe('B0'); // Most recent 173 | expect(result.backupTasks.recentTasks[19].upid).toBe('B19'); // 20th most recent 174 | }); 175 | 176 | test('should handle tasks with missing start or end times gracefully', () => { 177 | const now = Math.floor(Date.now() / 1000); 178 | const tasks = [ 179 | { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 100, endtime: now - 50 }, 180 | { upid: 'B2', worker_type: 'backup', status: 'OK', starttime: null, endtime: now - 150 }, // Missing starttime 181 | { upid: 'B3', worker_type: 'backup', status: 'OK', starttime: now - 200, endtime: undefined }, // Missing endtime 182 | { upid: 'B4', worker_type: 'backup', status: 'OK', starttime: null, endtime: null }, // Missing both 183 | ]; 184 | const result = processPbsTasks(tasks); 185 | const recent = result.backupTasks.recentTasks; 186 | 187 | expect(recent).toHaveLength(4); 188 | // Sorting might be affected, but check formatting 189 | const taskB2 = recent.find(t => t.upid === 'B2'); 190 | const taskB3 = recent.find(t => t.upid === 'B3'); 191 | const taskB4 = recent.find(t => t.upid === 'B4'); 192 | 193 | expect(taskB2.duration).toBeNull(); 194 | expect(taskB3.duration).toBeNull(); 195 | expect(taskB4.duration).toBeNull(); 196 | 197 | // Check summary timestamps (should ignore tasks without endtime) 198 | expect(result.backupTasks.summary.lastOk).toBe(now - 50); // Only B1 has a valid endtime 199 | }); 200 | 201 | test('should handle different verification task types', () => { 202 | const now = Math.floor(Date.now() / 1000); 203 | const tasks = [ 204 | { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 100, endtime: now - 50 }, 205 | { upid: 'V2', type: 'verificationjob', status: 'OK', starttime: now - 200, endtime: now - 150 }, 206 | { upid: 'V3', type: 'verify_group', status: 'FAILED', starttime: now - 300, endtime: now - 250 }, 207 | ]; 208 | const result = processPbsTasks(tasks); 209 | 210 | expect(result.verificationTasks.summary.ok).toBe(2); 211 | expect(result.verificationTasks.summary.failed).toBe(1); 212 | expect(result.verificationTasks.summary.total).toBe(3); 213 | expect(result.verificationTasks.recentTasks).toHaveLength(3); 214 | expect(result.verificationTasks.recentTasks.map(t => t.upid)).toEqual(['V1', 'V2', 'V3']); // Sorted by start time 215 | }); 216 | 217 | test('should handle different prune/gc task types', () => { 218 | const now = Math.floor(Date.now() / 1000); 219 | const tasks = [ 220 | { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 100, endtime: now - 50 }, 221 | { upid: 'G1', type: 'garbage_collection', status: 'FAILED', starttime: now - 200, endtime: now - 150 }, 222 | ]; 223 | const result = processPbsTasks(tasks); 224 | 225 | expect(result.pruneTasks.summary.ok).toBe(1); 226 | expect(result.pruneTasks.summary.failed).toBe(1); 227 | expect(result.pruneTasks.summary.total).toBe(2); 228 | expect(result.pruneTasks.recentTasks).toHaveLength(2); 229 | expect(result.pruneTasks.recentTasks.map(t => t.upid)).toEqual(['P1', 'G1']); // Sorted by start time 230 | }); 231 | 232 | test('should return default structure for non-array input', () => { 233 | const result = processPbsTasks({}); // Pass an object instead of an array 234 | expect(result).toEqual({ 235 | backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 236 | verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 237 | syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 238 | pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, 239 | aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, 240 | }); 241 | }); 242 | 243 | }); 244 | -------------------------------------------------------------------------------- /server/thresholdRoutes.js: -------------------------------------------------------------------------------- 1 | const customThresholdManager = require('./customThresholds'); 2 | 3 | function setupThresholdRoutes(app) { 4 | console.log('[ThresholdRoutes] Setting up threshold API routes...'); 5 | 6 | // Get all custom thresholds 7 | app.get('/api/thresholds', async (req, res) => { 8 | try { 9 | console.log('[API] GET /api/thresholds called'); 10 | 11 | // Initialize the threshold manager if needed 12 | if (!customThresholdManager.initialized) { 13 | await customThresholdManager.init(); 14 | } 15 | 16 | const thresholds = customThresholdManager.getAllThresholds(); 17 | console.log('[API] Retrieved', thresholds.length, 'threshold configurations'); 18 | res.json({ success: true, data: thresholds }); 19 | } catch (error) { 20 | console.error('[API /api/thresholds] Error:', error); 21 | res.status(500).json({ 22 | success: false, 23 | error: error.message || 'Failed to get thresholds' 24 | }); 25 | } 26 | }); 27 | 28 | // Get thresholds for specific VM/LXC 29 | app.get('/api/thresholds/:endpointId/:nodeId/:vmid', async (req, res) => { 30 | try { 31 | const { endpointId, nodeId, vmid } = req.params; 32 | 33 | if (!customThresholdManager.initialized) { 34 | await customThresholdManager.init(); 35 | } 36 | 37 | const thresholds = customThresholdManager.getThresholds(endpointId, nodeId, vmid); 38 | 39 | if (thresholds) { 40 | res.json({ success: true, data: thresholds }); 41 | } else { 42 | res.status(404).json({ 43 | success: false, 44 | error: 'No custom thresholds found for this VM/LXC' 45 | }); 46 | } 47 | } catch (error) { 48 | console.error('[API /api/thresholds/:endpointId/:nodeId/:vmid] Error:', error); 49 | res.status(500).json({ 50 | success: false, 51 | error: error.message || 'Failed to get thresholds' 52 | }); 53 | } 54 | }); 55 | 56 | // Set custom thresholds for VM/LXC 57 | app.post('/api/thresholds/:endpointId/:nodeId/:vmid', async (req, res) => { 58 | try { 59 | let { endpointId, nodeId, vmid } = req.params; 60 | const { thresholds } = req.body; 61 | 62 | if (!thresholds) { 63 | return res.status(400).json({ 64 | success: false, 65 | error: 'Thresholds configuration is required' 66 | }); 67 | } 68 | 69 | // Handle auto-detect node 70 | if (nodeId === 'auto-detect') { 71 | const state = require('./state'); 72 | // Find the VM/LXC in the current state 73 | const allGuests = [...(state.vms || []), ...(state.containers || [])]; 74 | const guest = allGuests.find(g => 75 | g.endpointId === endpointId && g.id === vmid 76 | ); 77 | 78 | if (guest && guest.node) { 79 | nodeId = guest.node; 80 | console.log(`[ThresholdRoutes] Auto-detected node '${nodeId}' for ${endpointId}:${vmid}`); 81 | } else { 82 | // If we can't find the node, use a wildcard that will match any node 83 | nodeId = '*'; 84 | console.log(`[ThresholdRoutes] Could not auto-detect node for ${endpointId}:${vmid}, using wildcard`); 85 | } 86 | } 87 | 88 | if (!customThresholdManager.initialized) { 89 | await customThresholdManager.init(); 90 | } 91 | 92 | await customThresholdManager.setThresholds(endpointId, nodeId, vmid, thresholds); 93 | res.json({ success: true, message: 'Custom thresholds saved successfully' }); 94 | } catch (error) { 95 | console.error('[API /api/thresholds/:endpointId/:nodeId/:vmid] Error:', error); 96 | res.status(400).json({ 97 | success: false, 98 | error: error.message || 'Failed to save thresholds' 99 | }); 100 | } 101 | }); 102 | 103 | // Update existing custom thresholds 104 | app.put('/api/thresholds/:endpointId/:nodeId/:vmid', async (req, res) => { 105 | try { 106 | let { endpointId, nodeId, vmid } = req.params; 107 | const { thresholds } = req.body; 108 | 109 | if (!thresholds) { 110 | return res.status(400).json({ 111 | success: false, 112 | error: 'Thresholds configuration is required' 113 | }); 114 | } 115 | 116 | // Handle auto-detect node 117 | if (nodeId === 'auto-detect') { 118 | const state = require('./state'); 119 | // Find the VM/LXC in the current state 120 | const allGuests = [...(state.vms || []), ...(state.containers || [])]; 121 | const guest = allGuests.find(g => 122 | g.endpointId === endpointId && g.id === vmid 123 | ); 124 | 125 | if (guest && guest.node) { 126 | nodeId = guest.node; 127 | console.log(`[ThresholdRoutes] Auto-detected node '${nodeId}' for ${endpointId}:${vmid}`); 128 | } else { 129 | // If we can't find the node, use a wildcard that will match any node 130 | nodeId = '*'; 131 | console.log(`[ThresholdRoutes] Could not auto-detect node for ${endpointId}:${vmid}, using wildcard`); 132 | } 133 | } 134 | 135 | if (!customThresholdManager.initialized) { 136 | await customThresholdManager.init(); 137 | } 138 | 139 | // Check if thresholds exist 140 | const existing = customThresholdManager.getThresholds(endpointId, nodeId, vmid); 141 | if (!existing) { 142 | return res.status(404).json({ 143 | success: false, 144 | error: 'No custom thresholds found for this VM/LXC' 145 | }); 146 | } 147 | 148 | await customThresholdManager.setThresholds(endpointId, nodeId, vmid, thresholds); 149 | res.json({ success: true, message: 'Custom thresholds updated successfully' }); 150 | } catch (error) { 151 | console.error('[API /api/thresholds/:endpointId/:nodeId/:vmid] Error:', error); 152 | res.status(400).json({ 153 | success: false, 154 | error: error.message || 'Failed to update thresholds' 155 | }); 156 | } 157 | }); 158 | 159 | // Delete custom thresholds for VM/LXC 160 | app.delete('/api/thresholds/:endpointId/:nodeId/:vmid', async (req, res) => { 161 | try { 162 | const { endpointId, nodeId, vmid } = req.params; 163 | 164 | if (!customThresholdManager.initialized) { 165 | await customThresholdManager.init(); 166 | } 167 | 168 | const removed = await customThresholdManager.removeThresholds(endpointId, nodeId, vmid); 169 | 170 | if (removed) { 171 | res.json({ success: true, message: 'Custom thresholds removed successfully' }); 172 | } else { 173 | res.status(404).json({ 174 | success: false, 175 | error: 'No custom thresholds found for this VM/LXC' 176 | }); 177 | } 178 | } catch (error) { 179 | console.error('[API /api/thresholds/:endpointId/:nodeId/:vmid] Error:', error); 180 | res.status(500).json({ 181 | success: false, 182 | error: error.message || 'Failed to remove thresholds' 183 | }); 184 | } 185 | }); 186 | 187 | // Toggle custom thresholds enabled/disabled 188 | app.patch('/api/thresholds/:endpointId/:nodeId/:vmid/toggle', async (req, res) => { 189 | try { 190 | const { endpointId, nodeId, vmid } = req.params; 191 | const { enabled } = req.body; 192 | 193 | if (typeof enabled !== 'boolean') { 194 | return res.status(400).json({ 195 | success: false, 196 | error: 'Enabled flag must be a boolean' 197 | }); 198 | } 199 | 200 | if (!customThresholdManager.initialized) { 201 | await customThresholdManager.init(); 202 | } 203 | 204 | await customThresholdManager.toggleThresholds(endpointId, nodeId, vmid, enabled); 205 | res.json({ 206 | success: true, 207 | message: `Custom thresholds ${enabled ? 'enabled' : 'disabled'} successfully` 208 | }); 209 | } catch (error) { 210 | console.error('[API /api/thresholds/:endpointId/:nodeId/:vmid/toggle] Error:', error); 211 | res.status(400).json({ 212 | success: false, 213 | error: error.message || 'Failed to toggle thresholds' 214 | }); 215 | } 216 | }); 217 | 218 | console.log('[ThresholdRoutes] All threshold routes registered successfully'); 219 | } 220 | 221 | module.exports = { setupThresholdRoutes }; -------------------------------------------------------------------------------- /server/updateManager.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const semver = require('semver'); 3 | const fs = require('fs').promises; 4 | const path = require('path'); 5 | const { exec } = require('child_process'); 6 | const { promisify } = require('util'); 7 | const execAsync = promisify(exec); 8 | 9 | class UpdateManager { 10 | constructor() { 11 | this.githubRepo = 'rcourtman/Pulse'; 12 | this.currentVersion = require('../package.json').version; 13 | this.updateInProgress = false; 14 | } 15 | 16 | /** 17 | * Check for available updates 18 | */ 19 | async checkForUpdates() { 20 | try { 21 | console.log('[UpdateManager] Checking for updates...'); 22 | 23 | // Fetch latest release from GitHub 24 | const response = await axios.get( 25 | `https://api.github.com/repos/${this.githubRepo}/releases/latest`, 26 | { 27 | headers: { 28 | 'Accept': 'application/vnd.github.v3+json', 29 | 'User-Agent': 'Pulse-Update-Checker' 30 | }, 31 | timeout: 10000 32 | } 33 | ); 34 | 35 | const latestVersion = response.data.tag_name.replace('v', ''); 36 | const updateAvailable = semver.gt(latestVersion, this.currentVersion); 37 | 38 | const updateInfo = { 39 | currentVersion: this.currentVersion, 40 | latestVersion, 41 | updateAvailable, 42 | isDocker: this.isDockerEnvironment(), 43 | releaseNotes: response.data.body || 'No release notes available', 44 | releaseUrl: response.data.html_url, 45 | publishedAt: response.data.published_at, 46 | assets: response.data.assets.map(asset => ({ 47 | name: asset.name, 48 | size: asset.size, 49 | downloadUrl: asset.browser_download_url 50 | })) 51 | }; 52 | 53 | console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Docker: ${updateInfo.isDocker}`); 54 | return updateInfo; 55 | 56 | } catch (error) { 57 | console.error('[UpdateManager] Error checking for updates:', error.message); 58 | throw new Error(`Failed to check for updates: ${error.message}`); 59 | } 60 | } 61 | 62 | /** 63 | * Download update package 64 | */ 65 | async downloadUpdate(downloadUrl, progressCallback) { 66 | try { 67 | console.log('[UpdateManager] Downloading update from:', downloadUrl); 68 | 69 | const tempDir = path.join(__dirname, '..', 'temp'); 70 | await fs.mkdir(tempDir, { recursive: true }); 71 | 72 | const tempFile = path.join(tempDir, 'update.tar.gz'); 73 | 74 | // In test mode, create a mock tarball directly instead of downloading 75 | if (process.env.UPDATE_TEST_MODE === 'true' && downloadUrl.includes('/api/test/mock-update.tar.gz')) { 76 | console.log('[UpdateManager] Test mode: Creating mock update package...'); 77 | 78 | const tar = require('tar'); 79 | await tar.create({ 80 | gzip: true, 81 | file: tempFile, 82 | cwd: path.join(__dirname, '..'), 83 | filter: (path) => { 84 | return !path.includes('node_modules') && 85 | !path.includes('.git') && 86 | !path.includes('temp') && 87 | !path.includes('data/backups'); 88 | } 89 | }, ['.']); 90 | 91 | // Simulate download progress 92 | if (progressCallback) { 93 | for (let i = 0; i <= 100; i += 10) { 94 | progressCallback({ phase: 'download', progress: i }); 95 | await new Promise(resolve => setTimeout(resolve, 100)); 96 | } 97 | } 98 | 99 | console.log('[UpdateManager] Test mode: Mock package created successfully'); 100 | return tempFile; 101 | } 102 | 103 | // Normal download for real updates 104 | const response = await axios({ 105 | method: 'get', 106 | url: downloadUrl, 107 | responseType: 'stream', 108 | timeout: 300000 // 5 minutes 109 | }); 110 | 111 | const totalSize = parseInt(response.headers['content-length'], 10); 112 | let downloadedSize = 0; 113 | 114 | const writer = require('fs').createWriteStream(tempFile); 115 | 116 | response.data.on('data', (chunk) => { 117 | downloadedSize += chunk.length; 118 | if (progressCallback) { 119 | const progress = Math.round((downloadedSize / totalSize) * 100); 120 | progressCallback({ phase: 'download', progress }); 121 | } 122 | }); 123 | 124 | response.data.pipe(writer); 125 | 126 | return new Promise((resolve, reject) => { 127 | writer.on('finish', () => resolve(tempFile)); 128 | writer.on('error', (error) => { 129 | console.error('[UpdateManager] Writer error:', error); 130 | reject(error); 131 | }); 132 | response.data.on('error', (error) => { 133 | console.error('[UpdateManager] Response stream error:', error); 134 | writer.destroy(); 135 | reject(error); 136 | }); 137 | }); 138 | 139 | } catch (error) { 140 | console.error('[UpdateManager] Error downloading update:', error); 141 | console.error('[UpdateManager] Error details:', { 142 | message: error.message, 143 | code: error.code, 144 | response: error.response?.status, 145 | responseData: error.response?.data 146 | }); 147 | throw new Error(`Failed to download update: ${error.message || error.toString()}`); 148 | } 149 | } 150 | 151 | /** 152 | * Check if running in Docker 153 | */ 154 | isDockerEnvironment() { 155 | return process.env.DOCKER_DEPLOYMENT === 'true' || 156 | require('fs').existsSync('/.dockerenv') || 157 | (process.env.container === 'docker'); 158 | } 159 | 160 | /** 161 | * Apply update 162 | */ 163 | async applyUpdate(updateFile, progressCallback) { 164 | if (this.updateInProgress) { 165 | throw new Error('Update already in progress'); 166 | } 167 | 168 | // Check if running in Docker 169 | if (this.isDockerEnvironment()) { 170 | throw new Error( 171 | 'Automatic updates are not supported in Docker deployments. ' + 172 | 'Please update your Docker image by pulling the latest version:\n' + 173 | 'docker pull rcourtman/pulse:latest\n' + 174 | 'or update your docker-compose.yml to use the new version tag.' 175 | ); 176 | } 177 | 178 | this.updateInProgress = true; 179 | 180 | try { 181 | console.log('[UpdateManager] Applying update...'); 182 | 183 | // Create backup directory 184 | const backupDir = path.join(__dirname, '..', 'backup', `backup-${Date.now()}`); 185 | await fs.mkdir(backupDir, { recursive: true }); 186 | 187 | if (progressCallback) { 188 | progressCallback({ phase: 'backup', progress: 0 }); 189 | } 190 | 191 | // Backup critical files 192 | const filesToBackup = [ 193 | '.env', 194 | 'data/metrics.db', 195 | 'data/acknowledgements.json' 196 | ]; 197 | 198 | for (let i = 0; i < filesToBackup.length; i++) { 199 | const file = filesToBackup[i]; 200 | const sourcePath = path.join(__dirname, '..', file); 201 | const backupPath = path.join(backupDir, file); 202 | 203 | try { 204 | await fs.mkdir(path.dirname(backupPath), { recursive: true }); 205 | await fs.copyFile(sourcePath, backupPath); 206 | } catch (error) { 207 | if (error.code !== 'ENOENT') { 208 | console.warn(`[UpdateManager] Warning: Could not backup ${file}:`, error.message); 209 | } 210 | } 211 | 212 | if (progressCallback) { 213 | const progress = Math.round(((i + 1) / filesToBackup.length) * 100); 214 | progressCallback({ phase: 'backup', progress }); 215 | } 216 | } 217 | 218 | if (progressCallback) { 219 | progressCallback({ phase: 'extract', progress: 0 }); 220 | } 221 | 222 | // Extract update 223 | const tempExtractDir = path.join(__dirname, '..', 'temp', 'extract'); 224 | await fs.mkdir(tempExtractDir, { recursive: true }); 225 | 226 | await execAsync(`tar -xzf ${updateFile} -C ${tempExtractDir}`); 227 | 228 | if (progressCallback) { 229 | progressCallback({ phase: 'extract', progress: 100 }); 230 | } 231 | 232 | const pulseDir = path.join(__dirname, '..'); 233 | 234 | if (progressCallback) { 235 | progressCallback({ phase: 'apply', progress: 50 }); 236 | } 237 | 238 | // Apply update files 239 | console.log('[UpdateManager] Extracting update files...'); 240 | 241 | // List files to update (exclude config and data) 242 | const updateFiles = await fs.readdir(tempExtractDir); 243 | for (const file of updateFiles) { 244 | if (file === '.env' || file === 'data') continue; 245 | 246 | const sourcePath = path.join(tempExtractDir, file); 247 | const destPath = path.join(pulseDir, file); 248 | 249 | // Remove existing file/directory 250 | try { 251 | await fs.rm(destPath, { recursive: true, force: true }); 252 | } catch (e) { 253 | // Ignore errors 254 | } 255 | 256 | // Copy new file/directory 257 | await execAsync(`cp -rf "${sourcePath}" "${destPath}"`); 258 | } 259 | 260 | // Install dependencies 261 | console.log('[UpdateManager] Installing dependencies...'); 262 | await execAsync(`cd "${pulseDir}" && rm -rf node_modules && npm install --omit=dev`); 263 | 264 | if (progressCallback) { 265 | progressCallback({ phase: 'apply', progress: 100 }); 266 | } 267 | 268 | // Schedule restart 269 | console.log('[UpdateManager] Scheduling restart...'); 270 | setTimeout(async () => { 271 | console.log('[UpdateManager] Attempting restart...'); 272 | 273 | // In test mode or development, just exit (user needs to restart manually) 274 | if (process.env.UPDATE_TEST_MODE === 'true' || process.env.NODE_ENV === 'development') { 275 | console.log('[UpdateManager] Test/Dev mode: Please restart the server manually'); 276 | console.log('[UpdateManager] Exiting in 3 seconds...'); 277 | setTimeout(() => { 278 | process.exit(0); 279 | }, 3000); 280 | return; 281 | } 282 | 283 | // For production deployments, try various restart methods 284 | try { 285 | // Try systemctl first (Linux with systemd) 286 | await execAsync('sudo systemctl restart pulse'); 287 | console.log('[UpdateManager] Restarted via systemctl'); 288 | } catch (error) { 289 | try { 290 | // Try pm2 restart 291 | await execAsync('pm2 restart pulse'); 292 | console.log('[UpdateManager] Restarted via pm2'); 293 | } catch (error) { 294 | // Last resort: exit and hope something restarts us 295 | console.log('[UpdateManager] No restart mechanism found, exiting...'); 296 | process.exit(0); 297 | } 298 | } 299 | }, 2000); 300 | 301 | // Cleanup 302 | await fs.rm(path.join(__dirname, '..', 'temp'), { recursive: true, force: true }); 303 | 304 | return { 305 | success: true, 306 | message: 'Update applied successfully. The application will restart automatically.' 307 | }; 308 | 309 | } catch (error) { 310 | console.error('[UpdateManager] Error applying update:', error.message); 311 | this.updateInProgress = false; 312 | throw new Error(`Failed to apply update: ${error.message}`); 313 | } 314 | } 315 | 316 | /** 317 | * Get update status 318 | */ 319 | getUpdateStatus() { 320 | return { 321 | updateInProgress: this.updateInProgress, 322 | currentVersion: this.currentVersion 323 | }; 324 | } 325 | } 326 | 327 | module.exports = UpdateManager; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Screen reader only text */ 6 | .sr-only { 7 | position: absolute; 8 | width: 1px; 9 | height: 1px; 10 | padding: 0; 11 | margin: -1px; 12 | overflow: hidden; 13 | clip: rect(0, 0, 0, 0); 14 | white-space: nowrap; 15 | border-width: 0; 16 | } 17 | 18 | .overflow-y-hidden { 19 | overflow-y: hidden; 20 | } 21 | 22 | 23 | /* Enhanced focus indicators for accessibility */ 24 | @layer utilities { 25 | .focus-visible-ring { 26 | @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800; 27 | } 28 | 29 | .focus-visible-ring-inset { 30 | @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset; 31 | } 32 | } 33 | 34 | /* Ensure all interactive elements have visible focus */ 35 | button:focus-visible, 36 | a:focus-visible, 37 | input:focus-visible, 38 | select:focus-visible, 39 | textarea:focus-visible, 40 | [role="button"]:focus-visible, 41 | [role="tab"]:focus-visible, 42 | [tabindex]:focus-visible { 43 | @apply outline-none ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-800; 44 | } 45 | 46 | /* Special focus styles for tabs */ 47 | .tab:focus-visible { 48 | @apply outline-none ring-2 ring-blue-500 ring-inset; 49 | } 50 | 51 | /* Focus styles for table sortable headers */ 52 | th.sortable:focus-visible { 53 | @apply outline-none ring-2 ring-blue-500 ring-inset; 54 | } 55 | 56 | .subtle-stripes-light { 57 | background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.025) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.025) 50%, rgba(0, 0, 0, 0.025) 75%, transparent 75%, transparent); 58 | background-size: 10px 10px; 59 | } 60 | 61 | .subtle-stripes-dark { 62 | background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.02) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.02) 50%, rgba(255, 255, 255, 0.02) 75%, transparent 75%, transparent); 63 | background-size: 10px 10px; /* Explicitly set for dark mode too */ 64 | } 65 | 66 | /* Dynamic column widths for responsive tables */ 67 | :root { 68 | --name-col-width: 150px; 69 | --uptime-col-width: 80px; 70 | --pbs-name-col-width: 120px; 71 | --pbs-path-col-width: 150px; 72 | --backup-name-col-width: 150px; 73 | --backup-node-col-width: 80px; 74 | --backup-pbs-col-width: 100px; 75 | --backup-ds-col-width: 100px; 76 | --storage-name-col-width: 150px; 77 | --storage-type-col-width: 80px; 78 | } 79 | 80 | /* Make sticky name column respond to row hover - use opaque colors */ 81 | tr:hover td.sticky { 82 | @apply bg-gray-50 dark:bg-gray-700 !important; 83 | } 84 | 85 | /* Respect user's motion preferences */ 86 | @media (prefers-reduced-motion: reduce) { 87 | * { 88 | animation-duration: 0.01ms !important; 89 | animation-iteration-count: 1 !important; 90 | transition-duration: 0.01ms !important; 91 | scroll-behavior: auto !important; 92 | } 93 | 94 | .pulse-logo-circle { 95 | animation: none !important; 96 | } 97 | 98 | .animate-spin { 99 | animation: none !important; 100 | } 101 | 102 | .transition-all, 103 | .transition-colors, 104 | .transition-opacity { 105 | transition: none !important; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/public/js/config.js: -------------------------------------------------------------------------------- 1 | const PulseApp = window.PulseApp || {}; 2 | 3 | PulseApp.config = { 4 | AVERAGING_WINDOW_SIZE: 5, 5 | INITIAL_PBS_TASK_LIMIT: 5 6 | }; -------------------------------------------------------------------------------- /src/public/js/debounce.js: -------------------------------------------------------------------------------- 1 | // Debounce utility function 2 | function debounce(func, wait) { 3 | let timeout; 4 | return function executedFunction(...args) { 5 | const later = () => { 6 | clearTimeout(timeout); 7 | func(...args); 8 | }; 9 | clearTimeout(timeout); 10 | timeout = setTimeout(later, wait); 11 | }; 12 | } 13 | 14 | // Export for use in other modules 15 | if (typeof PulseApp !== 'undefined') { 16 | PulseApp.utils = PulseApp.utils || {}; 17 | PulseApp.utils.debounce = debounce; 18 | } -------------------------------------------------------------------------------- /src/public/js/hotReload.js: -------------------------------------------------------------------------------- 1 | (function setupHotReload() { 2 | const socket = io(); 3 | 4 | socket.on('hotReload', function() { 5 | window.location.reload(); 6 | }); 7 | 8 | let wasConnected = false; 9 | socket.on('connect', function() { 10 | if (wasConnected) { 11 | console.log('Reconnected - refreshing page'); 12 | setTimeout(() => window.location.reload(), 500); 13 | } 14 | wasConnected = true; 15 | }); 16 | 17 | socket.on('disconnect', function(reason) { 18 | wasConnected = false; 19 | }); 20 | 21 | })(); -------------------------------------------------------------------------------- /src/public/js/main.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const PulseApp = window.PulseApp || {}; 3 | 4 | function updateAllUITables() { 5 | if (!PulseApp.state || !PulseApp.state.get('initialDataReceived')) { 6 | return; 7 | } 8 | 9 | // Global scroll preservation for all main scrollable containers 10 | const allContainers = [ 11 | document.querySelector('.table-container'), // Main dashboard table 12 | document.querySelector('#node-summary-cards-container'), // Node cards 13 | document.querySelector('#storage-info-content'), // Storage tab 14 | document.querySelector('#pbs-instances-container'), // PBS tab 15 | document.querySelector('#backups-table-container'), // Backups tab 16 | // Also find any overflow-x-auto or overflow-y-auto containers that might be scrolled 17 | ...Array.from(document.querySelectorAll('.overflow-x-auto, .overflow-y-auto, [style*="overflow"]')) 18 | ]; 19 | 20 | const scrollableContainers = allContainers.filter((container, index, array) => { 21 | // Remove duplicates and null containers 22 | return container && array.indexOf(container) === index; 23 | }); 24 | 25 | // Capture all scroll positions before any updates 26 | const scrollPositions = scrollableContainers.map(container => { 27 | const position = { 28 | element: container, 29 | scrollLeft: container.scrollLeft, 30 | scrollTop: container.scrollTop 31 | }; 32 | if (position.scrollLeft > 0 || position.scrollTop > 0) { 33 | } 34 | return position; 35 | }); 36 | 37 | const nodesData = PulseApp.state.get('nodesData'); 38 | const pbsDataArray = PulseApp.state.get('pbsDataArray'); 39 | 40 | // Disable individual scroll preservation to avoid conflicts 41 | const originalPreserveScrollPosition = PulseApp.utils.preserveScrollPosition; 42 | PulseApp.utils.preserveScrollPosition = (element, updateFn) => { 43 | // Just run the update function without scroll preservation 44 | updateFn(); 45 | }; 46 | 47 | // PulseApp.ui.nodes?.updateNodesTable(nodesData); // REMOVED - Nodes table is gone 48 | PulseApp.ui.nodes?.updateNodeSummaryCards(nodesData); 49 | PulseApp.ui.dashboard?.updateDashboardTable(); 50 | PulseApp.ui.storage?.updateStorageInfo(); 51 | PulseApp.ui.pbs?.updatePbsInfo(pbsDataArray); 52 | PulseApp.ui.backups?.updateBackupsTab(); 53 | 54 | // Restore the original function 55 | PulseApp.utils.preserveScrollPosition = originalPreserveScrollPosition; 56 | 57 | // Update tab availability based on PBS data 58 | PulseApp.ui.tabs?.updateTabAvailability(); 59 | 60 | updateLoadingOverlayVisibility(); // Call the helper function 61 | 62 | PulseApp.thresholds?.logging?.checkThresholdViolations(); 63 | 64 | // Update alerts when state changes 65 | const state = PulseApp.state.getFullState(); 66 | PulseApp.alerts?.updateAlertsFromState?.(state); 67 | 68 | // Check and show configuration banner if needed 69 | PulseApp.ui.configBanner?.checkAndShowBanner(); 70 | 71 | // Simple and direct scroll preservation - just focus on main table 72 | const mainTableContainer = document.querySelector('.table-container'); 73 | if (mainTableContainer) { 74 | const savedScrollTop = mainTableContainer.scrollTop; 75 | const savedScrollLeft = mainTableContainer.scrollLeft; 76 | 77 | if (savedScrollTop > 0 || savedScrollLeft > 0) { 78 | // Use multiple restoration strategies 79 | const restoreMainScroll = () => { 80 | mainTableContainer.scrollTop = savedScrollTop; 81 | mainTableContainer.scrollLeft = savedScrollLeft; 82 | }; 83 | 84 | // Try multiple timings 85 | setTimeout(restoreMainScroll, 0); 86 | setTimeout(restoreMainScroll, 10); 87 | setTimeout(restoreMainScroll, 50); 88 | setTimeout(restoreMainScroll, 100); 89 | setTimeout(restoreMainScroll, 200); 90 | requestAnimationFrame(restoreMainScroll); 91 | requestAnimationFrame(() => setTimeout(restoreMainScroll, 0)); 92 | 93 | // Also try using scrollTo method 94 | setTimeout(() => { 95 | mainTableContainer.scrollTo(savedScrollLeft, savedScrollTop); 96 | }, 50); 97 | 98 | setTimeout(() => { 99 | if (Math.abs(mainTableContainer.scrollTop - savedScrollTop) > 10) { 100 | // Try one more aggressive approach 101 | mainTableContainer.scrollTo({ 102 | top: savedScrollTop, 103 | left: savedScrollLeft, 104 | behavior: 'instant' 105 | }); 106 | } else { 107 | } 108 | }, 300); 109 | } 110 | } else { 111 | } 112 | } 113 | 114 | function updateLoadingOverlayVisibility() { 115 | const loadingOverlay = document.getElementById('loading-overlay'); 116 | if (!loadingOverlay) return; 117 | 118 | const isConnected = PulseApp.socketHandler?.isConnected(); 119 | const initialDataReceived = PulseApp.state?.get('initialDataReceived'); 120 | 121 | if (loadingOverlay.style.display !== 'none') { // Only act if currently visible 122 | if (isConnected && initialDataReceived) { 123 | loadingOverlay.style.display = 'none'; 124 | } else if (!isConnected) { 125 | } 126 | // If initialDataReceived is false, or socket is connected but no data yet, overlay remains. 127 | } else if (!isConnected && loadingOverlay.style.display === 'none') { 128 | // If overlay is hidden but socket disconnects, re-show it. 129 | loadingOverlay.style.display = 'flex'; // Or 'block', or its original display type 130 | } 131 | } 132 | 133 | function initializeModules() { 134 | PulseApp.state?.init?.(); 135 | PulseApp.config?.init?.(); 136 | PulseApp.utils?.init?.(); 137 | PulseApp.theme?.init?.(); 138 | // Ensure socketHandler.init receives both callbacks if it's designed to accept them 139 | // If socketHandler.init only expects one, this might need adjustment in socketHandler.js 140 | PulseApp.socketHandler?.init?.(updateAllUITables, updateLoadingOverlayVisibility); 141 | PulseApp.tooltips?.init?.(); 142 | PulseApp.alerts?.init?.(); 143 | 144 | PulseApp.ui = PulseApp.ui || {}; 145 | PulseApp.ui.tabs?.init?.(); 146 | PulseApp.ui.nodes?.init?.(); 147 | PulseApp.ui.dashboard?.init?.(); 148 | PulseApp.ui.storage?.init?.(); 149 | PulseApp.ui.pbs?.initPbsEventListeners?.(); 150 | PulseApp.ui.backups?.init?.(); 151 | PulseApp.ui.settings?.init?.(); 152 | PulseApp.ui.thresholds?.init?.(); 153 | PulseApp.ui.common?.init?.(); 154 | 155 | PulseApp.thresholds = PulseApp.thresholds || {}; 156 | } 157 | 158 | function validateCriticalElements() { 159 | const criticalElements = [ 160 | 'connection-status', 161 | 'main-table', 162 | 'dashboard-search', 163 | 'dashboard-status-text', 164 | 'app-version' 165 | ]; 166 | let allFound = true; 167 | criticalElements.forEach(id => { 168 | if (!document.getElementById(id)) { 169 | console.error(`Critical element #${id} not found!`); 170 | } 171 | }); 172 | if (!document.querySelector('#main-table tbody')) { 173 | console.error('Critical element #main-table tbody not found!'); 174 | } 175 | return allFound; 176 | } 177 | 178 | function fetchVersion() { 179 | const versionSpan = document.getElementById('app-version'); 180 | if (!versionSpan) { 181 | console.error('Version span element not found'); 182 | return; 183 | } 184 | 185 | fetch('/api/version') 186 | .then(response => { 187 | if (!response.ok) { 188 | throw new Error(`HTTP error! status: ${response.status}`); 189 | } 190 | return response.json(); 191 | }) 192 | .then(data => { 193 | if (data.version) { 194 | versionSpan.textContent = data.version; 195 | 196 | // Check if update is available 197 | if (data.updateAvailable && data.latestVersion) { 198 | // Check if update indicator already exists 199 | const existingIndicator = document.getElementById('update-indicator'); 200 | if (!existingIndicator) { 201 | // Create update indicator 202 | const updateIndicator = document.createElement('span'); 203 | updateIndicator.id = 'update-indicator'; 204 | updateIndicator.className = 'ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; 205 | updateIndicator.innerHTML = ` 206 | 207 | 208 | 209 | v${data.latestVersion} available 210 | `; 211 | updateIndicator.title = 'Click to view the latest release'; 212 | updateIndicator.style.cursor = 'pointer'; 213 | updateIndicator.addEventListener('click', (e) => { 214 | e.preventDefault(); 215 | e.stopPropagation(); 216 | window.open('https://github.com/rcourtman/Pulse/releases/latest', '_blank'); 217 | }); 218 | 219 | // Insert after version link 220 | versionSpan.parentNode.insertBefore(updateIndicator, versionSpan.nextSibling); 221 | } 222 | } else { 223 | // Remove update indicator if no update available 224 | const existingIndicator = document.getElementById('update-indicator'); 225 | if (existingIndicator) { 226 | existingIndicator.remove(); 227 | } 228 | } 229 | } else { 230 | versionSpan.textContent = 'unknown'; 231 | } 232 | }) 233 | .catch(error => { 234 | console.error('Error fetching version:', error); 235 | versionSpan.textContent = 'error'; 236 | }); 237 | } 238 | 239 | if (!validateCriticalElements()) { 240 | console.error("Stopping JS execution due to missing critical elements."); 241 | return; 242 | } 243 | 244 | initializeModules(); 245 | 246 | // Check if configuration is missing or contains placeholders and automatically open settings modal 247 | checkAndOpenSettingsIfNeeded(); 248 | 249 | // Fetch version immediately and retry after a short delay if needed 250 | fetchVersion(); 251 | 252 | // Also try again after DOM is fully ready and socket might be connected 253 | setTimeout(() => { 254 | const versionSpan = document.getElementById('app-version'); 255 | if (versionSpan && (versionSpan.textContent === 'loading...' || versionSpan.textContent === 'error')) { 256 | fetchVersion(); 257 | } 258 | }, 2000); 259 | 260 | // Periodically check for updates (every 6 hours) 261 | setInterval(() => { 262 | fetchVersion(); 263 | }, 6 * 60 * 60 * 1000); 264 | 265 | /** 266 | * Check if configuration is missing or contains placeholder values and open settings modal 267 | */ 268 | async function checkAndOpenSettingsIfNeeded() { 269 | try { 270 | // Wait a moment for the socket connection to establish and initial data to arrive 271 | setTimeout(async () => { 272 | try { 273 | const response = await fetch('/api/health'); 274 | if (response.ok) { 275 | const health = await response.json(); 276 | 277 | // Check if configuration has placeholder values or no data is available 278 | if (health.system && health.system.configPlaceholder) { 279 | console.log('[Main] Configuration contains placeholder values, opening settings modal...'); 280 | 281 | // Wait for settings module to be fully initialized 282 | setTimeout(() => { 283 | if (PulseApp.ui.settings && typeof PulseApp.ui.settings.openModal === 'function') { 284 | PulseApp.ui.settings.openModal(); 285 | } else { 286 | console.warn('[Main] Settings module not available for auto-open'); 287 | } 288 | }, 500); 289 | } else { 290 | console.log('[Main] Configuration appears valid, not opening settings modal'); 291 | } 292 | } 293 | } catch (error) { 294 | console.error('[Main] Error checking configuration status:', error); 295 | } 296 | }, 2000); // Wait 2 seconds for everything to settle 297 | } catch (error) { 298 | console.error('[Main] Error in checkAndOpenSettingsIfNeeded:', error); 299 | } 300 | } 301 | }); 302 | -------------------------------------------------------------------------------- /src/public/js/theme.js: -------------------------------------------------------------------------------- 1 | PulseApp.theme = (() => { 2 | const htmlElement = document.documentElement; 3 | const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); 4 | let themeToggleButton = null; 5 | 6 | function applyTheme(theme) { 7 | if (theme === 'dark') { 8 | htmlElement.classList.add('dark'); 9 | localStorage.setItem('theme', 'dark'); 10 | } else { 11 | htmlElement.classList.remove('dark'); 12 | localStorage.setItem('theme', 'light'); 13 | } 14 | } 15 | 16 | function init() { 17 | themeToggleButton = document.getElementById('theme-toggle-button'); 18 | const savedTheme = localStorage.getItem('theme'); 19 | const initialTheme = savedTheme || (prefersDarkScheme.matches ? 'dark' : 'light'); 20 | applyTheme(initialTheme); 21 | 22 | if (themeToggleButton) { 23 | themeToggleButton.addEventListener('click', function() { 24 | const currentIsDark = htmlElement.classList.contains('dark'); 25 | applyTheme(currentIsDark ? 'light' : 'dark'); 26 | }); 27 | } else { 28 | console.warn('Element #theme-toggle-button not found - theme switching disabled.'); 29 | } 30 | } 31 | 32 | return { 33 | init 34 | }; 35 | })(); -------------------------------------------------------------------------------- /src/public/js/tooltips.js: -------------------------------------------------------------------------------- 1 | PulseApp.tooltips = (() => { 2 | let tooltipElement = null; 3 | let sliderValueTooltip = null; 4 | 5 | function init() { 6 | tooltipElement = document.getElementById('custom-tooltip'); 7 | sliderValueTooltip = document.getElementById('slider-value-tooltip'); 8 | 9 | if (!tooltipElement) { 10 | console.warn('Element #custom-tooltip not found - tooltips will not work.'); 11 | return; // Don't attach listeners if the element is missing 12 | } 13 | if (!sliderValueTooltip) { 14 | console.warn('Element #slider-value-tooltip not found - slider values will not display on drag.'); 15 | // Continue initialization for general tooltips even if slider tooltip is missing 16 | } 17 | 18 | tooltipElement.classList.remove('duration-100'); 19 | tooltipElement.classList.add('duration-50'); 20 | 21 | document.body.addEventListener('mouseover', handleMouseOver); 22 | document.body.addEventListener('mouseout', handleMouseOut); 23 | document.body.addEventListener('mousemove', handleMouseMove); 24 | 25 | // Hide tooltip when mouse leaves the document 26 | document.addEventListener('mouseleave', hideTooltip); 27 | 28 | // Hide tooltip on scroll to prevent stuck tooltips 29 | window.addEventListener('scroll', hideTooltip, true); 30 | 31 | document.addEventListener('mouseup', hideSliderTooltip); 32 | document.addEventListener('touchend', hideSliderTooltip); 33 | } 34 | 35 | function handleMouseOver(event) { 36 | const target = event.target.closest('[data-tooltip], .metric-tooltip-trigger, .storage-tooltip-trigger, .truncate'); 37 | if (target) { 38 | let tooltipText = target.getAttribute('data-tooltip'); 39 | 40 | // Auto-generate tooltip for truncated text 41 | if (!tooltipText && target.classList.contains('truncate')) { 42 | const fullText = target.textContent.trim(); 43 | const title = target.getAttribute('title'); 44 | // Only show tooltip if text is actually truncated 45 | if ((title && title !== fullText) || target.scrollWidth > target.clientWidth) { 46 | tooltipText = title || fullText; 47 | } 48 | } 49 | 50 | if (tooltipText && tooltipElement) { 51 | tooltipElement.textContent = tooltipText; 52 | positionTooltip(event); 53 | tooltipElement.classList.remove('hidden', 'opacity-0'); 54 | tooltipElement.classList.add('opacity-100'); 55 | } 56 | } 57 | } 58 | 59 | function handleMouseOut(event) { 60 | const target = event.target.closest('[data-tooltip], .metric-tooltip-trigger, .storage-tooltip-trigger, .truncate'); 61 | if (!target) return; 62 | 63 | // Check if we're actually leaving the tooltip trigger element 64 | const relatedTarget = event.relatedTarget; 65 | if (relatedTarget && target.contains(relatedTarget)) { 66 | return; // We're still within the same tooltip trigger 67 | } 68 | 69 | if (tooltipElement) { 70 | tooltipElement.classList.add('hidden', 'opacity-0'); 71 | tooltipElement.classList.remove('opacity-100'); 72 | } 73 | } 74 | 75 | function handleMouseMove(event) { 76 | if (tooltipElement && !tooltipElement.classList.contains('hidden')) { 77 | const target = event.target.closest('[data-tooltip], .metric-tooltip-trigger, .storage-tooltip-trigger, .truncate'); 78 | if (target) { 79 | positionTooltip(event); 80 | } 81 | // Don't hide on mousemove - let mouseout handle it properly 82 | } 83 | } 84 | 85 | function positionTooltip(event) { 86 | if (!tooltipElement) return; 87 | const offsetX = 10; 88 | const offsetY = 15; 89 | tooltipElement.style.left = `${event.pageX + offsetX}px`; 90 | tooltipElement.style.top = `${event.pageY + offsetY}px`; 91 | } 92 | 93 | function updateSliderTooltip(sliderElement) { 94 | if (!sliderValueTooltip || !sliderElement) return; 95 | 96 | const type = sliderElement.id.replace('threshold-slider-', ''); 97 | if (!PulseApp.state.getThresholdState()[type]) return; // Only process actual threshold sliders 98 | 99 | const numericValue = parseInt(sliderElement.value); 100 | let displayText = `${numericValue}%`; 101 | 102 | const rect = sliderElement.getBoundingClientRect(); 103 | const min = parseFloat(sliderElement.min); 104 | const max = parseFloat(sliderElement.max); 105 | const value = parseFloat(sliderElement.value); 106 | 107 | const percent = (max > min) ? (value - min) / (max - min) : 0; 108 | 109 | const thumbWidthEstimate = 16; 110 | let thumbX = rect.left + (percent * (rect.width - thumbWidthEstimate)) + (thumbWidthEstimate / 2); 111 | 112 | sliderValueTooltip.textContent = displayText; 113 | sliderValueTooltip.classList.remove('hidden'); 114 | const tooltipRect = sliderValueTooltip.getBoundingClientRect(); 115 | 116 | const posX = thumbX - (tooltipRect.width / 2); 117 | const posY = rect.top - tooltipRect.height - 5; 118 | 119 | sliderValueTooltip.style.left = `${posX}px`; 120 | sliderValueTooltip.style.top = `${posY}px`; 121 | } 122 | 123 | function hideSliderTooltip() { 124 | if (sliderValueTooltip) { 125 | sliderValueTooltip.classList.add('hidden'); 126 | } 127 | } 128 | 129 | function showTooltip(event, content) { 130 | if (!tooltipElement) return; 131 | 132 | tooltipElement.innerHTML = content; 133 | positionTooltip(event); 134 | tooltipElement.classList.remove('hidden', 'opacity-0'); 135 | tooltipElement.classList.add('opacity-100'); 136 | } 137 | 138 | function hideTooltip() { 139 | if (tooltipElement) { 140 | tooltipElement.classList.add('hidden', 'opacity-0'); 141 | tooltipElement.classList.remove('opacity-100'); 142 | } 143 | } 144 | 145 | return { 146 | init, 147 | updateSliderTooltip, 148 | hideSliderTooltip, 149 | showTooltip, 150 | hideTooltip 151 | }; 152 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/backup-summary-cards.js: -------------------------------------------------------------------------------- 1 | PulseApp.ui = PulseApp.ui || {}; 2 | 3 | PulseApp.ui.backupSummaryCards = (() => { 4 | 5 | function calculateBackupStatistics(backupData, guestId, guestNode, guestEndpointId) { 6 | const now = Date.now(); 7 | const stats = { 8 | lastBackup: { time: null, type: null, status: 'none' }, 9 | coverage: { daily: 0, weekly: 0, monthly: 0 }, 10 | protected: { count: 0, oldestDate: null, coverage: 0 }, 11 | health: { score: 0, issues: [] } 12 | }; 13 | 14 | // Find most recent backup across all types 15 | const allBackups = []; 16 | 17 | ['pbsSnapshots', 'pveBackups', 'vmSnapshots'].forEach(type => { 18 | if (!backupData[type]) return; 19 | 20 | const items = guestId 21 | ? backupData[type].filter(item => { 22 | // Match vmid 23 | const itemVmid = item.vmid || item['backup-id'] || item.backupVMID; 24 | if (parseInt(itemVmid, 10) !== parseInt(guestId, 10)) return false; 25 | 26 | // For PBS backups (centralized), don't filter by node 27 | if (type === 'pbsSnapshots') return true; 28 | 29 | // For PVE backups and snapshots (node-specific), match node/endpoint 30 | const itemNode = item.node; 31 | const itemEndpoint = item.endpointId; 32 | 33 | // Match by node if available 34 | if (guestNode && itemNode) { 35 | return itemNode === guestNode; 36 | } 37 | 38 | // Match by endpointId if available 39 | if (guestEndpointId && itemEndpoint) { 40 | return itemEndpoint === guestEndpointId; 41 | } 42 | 43 | // If no node/endpoint info available, include it (fallback) 44 | return true; 45 | }) 46 | : backupData[type]; 47 | 48 | items.forEach(item => { 49 | const timestamp = item.ctime || item.snaptime || item['backup-time']; 50 | if (timestamp) { 51 | allBackups.push({ 52 | time: timestamp * 1000, 53 | type: type, 54 | protected: item.protected || false, 55 | verification: item.verification 56 | }); 57 | } 58 | }); 59 | }); 60 | 61 | // Sort by time descending 62 | allBackups.sort((a, b) => b.time - a.time); 63 | 64 | // Last backup info 65 | if (allBackups.length > 0) { 66 | const last = allBackups[0]; 67 | stats.lastBackup = { 68 | time: last.time, 69 | type: last.type, 70 | status: last.verification?.state === 'failed' ? 'failed' : 'success', 71 | age: now - last.time 72 | }; 73 | } 74 | 75 | // Calculate coverage (how many backups in each period) 76 | const oneDayAgo = now - 24 * 60 * 60 * 1000; 77 | const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000; 78 | const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000; 79 | 80 | allBackups.forEach(backup => { 81 | if (backup.time >= oneDayAgo) stats.coverage.daily++; 82 | if (backup.time >= oneWeekAgo) stats.coverage.weekly++; 83 | if (backup.time >= oneMonthAgo) stats.coverage.monthly++; 84 | }); 85 | 86 | // Protected backups analysis 87 | const protectedBackups = allBackups.filter(b => b.protected); 88 | stats.protected.count = protectedBackups.length; 89 | if (protectedBackups.length > 0) { 90 | const oldestProtected = protectedBackups[protectedBackups.length - 1]; 91 | stats.protected.oldestDate = oldestProtected.time; 92 | stats.protected.coverage = Math.floor((now - oldestProtected.time) / (24 * 60 * 60 * 1000)); 93 | } 94 | 95 | return stats; 96 | } 97 | 98 | return { 99 | calculateBackupStatistics 100 | }; 101 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/config-banner.js: -------------------------------------------------------------------------------- 1 | // Configuration banner component 2 | PulseApp.ui = PulseApp.ui || {}; 3 | 4 | PulseApp.ui.configBanner = (() => { 5 | let bannerElement = null; 6 | let isShowing = false; 7 | 8 | function createBanner() { 9 | const banner = document.createElement('div'); 10 | banner.id = 'config-banner'; 11 | banner.className = 'fixed top-0 left-0 right-0 bg-yellow-500 dark:bg-yellow-600 text-white px-4 py-3 z-50 shadow-lg transform -translate-y-full transition-transform duration-300 ease-in-out'; 12 | 13 | banner.innerHTML = ` 14 |
15 |
16 | 17 | 18 | 19 |
20 |

Configuration Required

21 |

Pulse needs to be configured with your Proxmox credentials to start monitoring.

22 |
23 |
24 |
25 | 27 | Configure Now 28 | 29 | 35 |
36 |
37 | `; 38 | 39 | document.body.appendChild(banner); 40 | return banner; 41 | } 42 | 43 | function show() { 44 | if (isShowing) return; 45 | 46 | if (!bannerElement) { 47 | bannerElement = createBanner(); 48 | } 49 | 50 | // Add space to the body to prevent content overlap 51 | document.body.style.paddingTop = '80px'; 52 | 53 | // Trigger the slide-down animation 54 | setTimeout(() => { 55 | bannerElement.classList.remove('-translate-y-full'); 56 | }, 100); 57 | 58 | isShowing = true; 59 | } 60 | 61 | function hide() { 62 | if (!isShowing || !bannerElement) return; 63 | 64 | // Slide up animation 65 | bannerElement.classList.add('-translate-y-full'); 66 | 67 | // Remove padding after animation 68 | setTimeout(() => { 69 | document.body.style.paddingTop = ''; 70 | isShowing = false; 71 | }, 300); 72 | } 73 | 74 | function checkAndShowBanner() { 75 | const isConfigPlaceholder = PulseApp.state?.get('isConfigPlaceholder'); 76 | 77 | if (isConfigPlaceholder) { 78 | show(); 79 | } else { 80 | hide(); 81 | } 82 | } 83 | 84 | return { 85 | show, 86 | hide, 87 | checkAndShowBanner 88 | }; 89 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/empty-states.js: -------------------------------------------------------------------------------- 1 | // Empty state UI components 2 | PulseApp.ui = PulseApp.ui || {}; 3 | 4 | PulseApp.ui.emptyStates = (() => { 5 | 6 | function createEmptyState(type, context = {}) { 7 | const emptyStates = { 8 | 'no-guests': { 9 | icon: ` 10 | 11 | 12 | `, 13 | title: 'No Virtual Machines or Containers', 14 | message: 'No VMs or containers are currently configured on this node.', 15 | actions: [] 16 | }, 17 | 'no-results': { 18 | icon: ` 19 | 20 | 21 | 22 | 23 | `, 24 | title: 'No Matching Results', 25 | message: _buildFilterMessage(context), 26 | actions: [{ 27 | text: 'Clear Filters', 28 | onclick: 'PulseApp.ui.common.resetDashboardView()' 29 | }] 30 | }, 31 | 'no-storage': { 32 | icon: ` 33 | 34 | 35 | 36 | `, 37 | title: 'No Storage Configured', 38 | message: 'No storage repositories are configured on this system.', 39 | actions: [] 40 | }, 41 | 'no-backups': { 42 | icon: ` 43 | 44 | 45 | `, 46 | title: 'No Backups Found', 47 | message: context.filtered ? 'No backups match your current filters.' : 'No backup data is available yet.', 48 | actions: context.filtered ? [{ 49 | text: 'Clear Filters', 50 | onclick: 'PulseApp.ui.backups.resetBackupsView()' 51 | }] : [] 52 | }, 53 | 'no-pbs': { 54 | icon: ` 55 | 56 | 57 | 58 | `, 59 | title: 'No Proxmox Backup Servers', 60 | message: 'No PBS instances are configured or available.', 61 | actions: [] 62 | }, 63 | 'loading': { 64 | icon: `
65 |
66 |
`, 67 | title: 'Loading...', 68 | message: 'Fetching data from the server.', 69 | actions: [] 70 | }, 71 | 'error': { 72 | icon: ` 73 | 74 | 75 | 76 | `, 77 | title: 'Error Loading Data', 78 | message: context.error || 'Failed to load data. Please try again.', 79 | actions: [{ 80 | text: 'Retry', 81 | onclick: 'location.reload()' 82 | }] 83 | }, 84 | 'config-required': { 85 | icon: ` 86 | 87 | 88 | 89 | `, 90 | title: 'Configuration Required', 91 | message: 'Pulse needs to be configured with your Proxmox credentials before it can start monitoring.', 92 | actions: [{ 93 | text: 'Configure Now', 94 | onclick: 'window.location.href="/setup.html"' 95 | }] 96 | } 97 | }; 98 | 99 | const state = emptyStates[type] || emptyStates['no-results']; 100 | 101 | return ` 102 |
103 | ${state.icon} 104 |

${state.title}

105 |

${state.message}

106 | ${state.actions.length > 0 ? ` 107 |
108 | ${state.actions.map(action => ` 109 | 112 | `).join('')} 113 |
114 | ` : ''} 115 |
116 | `; 117 | } 118 | 119 | function _buildFilterMessage(context) { 120 | const filters = []; 121 | 122 | if (context.filterType && context.filterType !== 'all') { 123 | filters.push(`Type: ${context.filterType.toUpperCase()}`); 124 | } 125 | 126 | if (context.filterStatus && context.filterStatus !== 'all') { 127 | filters.push(`Status: ${context.filterStatus}`); 128 | } 129 | 130 | if (context.searchTerms && context.searchTerms.length > 0) { 131 | filters.push(`Search: "${context.searchTerms.join(', ')}"`); 132 | } 133 | 134 | if (context.thresholds && context.thresholds.length > 0) { 135 | filters.push(`Thresholds: ${context.thresholds.join(', ')}`); 136 | } 137 | 138 | if (filters.length === 0) { 139 | return 'No items match your current view.'; 140 | } 141 | 142 | return `No items found matching: ${filters.join(' • ')}`; 143 | } 144 | 145 | function createTableEmptyState(type, context, colspan) { 146 | const emptyStateHtml = createEmptyState(type, context); 147 | return `${emptyStateHtml}`; 148 | } 149 | 150 | return { 151 | createEmptyState, 152 | createTableEmptyState 153 | }; 154 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/loading-skeletons.js: -------------------------------------------------------------------------------- 1 | // Loading skeleton components for better perceived performance 2 | PulseApp.ui = PulseApp.ui || {}; 3 | 4 | PulseApp.ui.loadingSkeletons = (() => { 5 | 6 | function createTableRowSkeleton(columns = 11) { 7 | const cells = []; 8 | for (let i = 0; i < columns; i++) { 9 | cells.push(` 10 | 11 |
12 | 13 | `); 14 | } 15 | 16 | return ` 17 | 18 | ${cells.join('')} 19 | 20 | `; 21 | } 22 | 23 | function createTableSkeleton(rows = 5, columns = 11) { 24 | const skeletonRows = []; 25 | for (let i = 0; i < rows; i++) { 26 | skeletonRows.push(createTableRowSkeleton(columns)); 27 | } 28 | 29 | return skeletonRows.join(''); 30 | } 31 | 32 | function createNodeCardSkeleton() { 33 | return ` 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | `; 55 | } 56 | 57 | function createStorageRowSkeleton() { 58 | return ` 59 | 60 | 61 |
62 | 63 | 64 |
65 | 66 | 67 |
68 | 69 | 70 |
71 | 72 | 73 |
74 | 75 | 76 |
77 | 78 | 79 |
80 | 81 | 82 | `; 83 | } 84 | 85 | function createBackupRowSkeleton() { 86 | return ` 87 | 88 | 89 |
90 | 91 | 92 |
93 | 94 | 95 |
96 | 97 | 98 |
99 | 100 | 101 |
102 | 103 | 104 |
105 | 106 | 107 | `; 108 | } 109 | 110 | function showTableSkeleton(tableElement, rows = 5, columns = 11) { 111 | if (!tableElement) return; 112 | 113 | const tbody = tableElement.querySelector('tbody'); 114 | if (tbody) { 115 | tbody.innerHTML = createTableSkeleton(rows, columns); 116 | } 117 | } 118 | 119 | function showNodeCardsSkeleton(container, count = 3) { 120 | if (!container) return; 121 | 122 | const skeletons = []; 123 | for (let i = 0; i < count; i++) { 124 | skeletons.push(createNodeCardSkeleton()); 125 | } 126 | 127 | container.innerHTML = ` 128 |
129 | ${skeletons.join('')} 130 |
131 | `; 132 | } 133 | 134 | return { 135 | createTableRowSkeleton, 136 | createTableSkeleton, 137 | createNodeCardSkeleton, 138 | createStorageRowSkeleton, 139 | createBackupRowSkeleton, 140 | showTableSkeleton, 141 | showNodeCardsSkeleton 142 | }; 143 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/status-icons.js: -------------------------------------------------------------------------------- 1 | // Status icons component 2 | PulseApp.ui = PulseApp.ui || {}; 3 | 4 | PulseApp.ui.statusIcons = (() => { 5 | 6 | const icons = { 7 | vm: { 8 | svg: ``, 9 | label: 'Virtual Machine' 10 | }, 11 | lxc: { 12 | svg: ``, 13 | label: 'Container' 14 | }, 15 | running: { 16 | svg: ``, 17 | label: 'Running' 18 | }, 19 | stopped: { 20 | svg: ``, 21 | label: 'Stopped' 22 | }, 23 | paused: { 24 | svg: ``, 25 | label: 'Paused' 26 | }, 27 | error: { 28 | svg: ``, 29 | label: 'Error' 30 | } 31 | }; 32 | 33 | function createTypeIcon(type) { 34 | const isVM = type === 'VM' || type === 'qemu'; 35 | const icon = isVM ? icons.vm : icons.lxc; 36 | const colorClass = isVM 37 | ? 'text-blue-600 dark:text-blue-400' 38 | : 'text-green-600 dark:text-green-400'; 39 | 40 | return ` 41 | 42 | ${icon.svg} 43 | ${isVM ? 'VM' : 'LXC'} 44 | 45 | `; 46 | } 47 | 48 | function createStatusIcon(status) { 49 | let icon, colorClass, bgClass; 50 | 51 | switch(status) { 52 | case 'running': 53 | icon = icons.running; 54 | colorClass = 'text-green-500'; 55 | bgClass = ''; 56 | break; 57 | case 'stopped': 58 | icon = icons.stopped; 59 | colorClass = 'text-gray-600 dark:text-gray-400'; 60 | bgClass = ''; 61 | break; 62 | case 'paused': 63 | icon = icons.paused; 64 | colorClass = 'text-yellow-500'; 65 | bgClass = ''; 66 | break; 67 | default: 68 | icon = icons.error; 69 | colorClass = 'text-red-500'; 70 | bgClass = ''; 71 | } 72 | 73 | return `${icon.svg}`; 74 | } 75 | 76 | function createStatusBadge(status) { 77 | let icon, textColor, bgColor, text; 78 | 79 | switch(status) { 80 | case 'running': 81 | icon = icons.running; 82 | textColor = 'text-green-700 dark:text-green-300'; 83 | bgColor = 'bg-green-100 dark:bg-green-900/30'; 84 | text = 'Running'; 85 | break; 86 | case 'stopped': 87 | icon = icons.stopped; 88 | textColor = 'text-gray-700 dark:text-gray-300'; 89 | bgColor = 'bg-gray-100 dark:bg-gray-800/30'; 90 | text = 'Stopped'; 91 | break; 92 | case 'paused': 93 | icon = icons.paused; 94 | textColor = 'text-yellow-700 dark:text-yellow-300'; 95 | bgColor = 'bg-yellow-100 dark:bg-yellow-900/30'; 96 | text = 'Paused'; 97 | break; 98 | default: 99 | icon = icons.error; 100 | textColor = 'text-red-700 dark:text-red-300'; 101 | bgColor = 'bg-red-100 dark:bg-red-900/30'; 102 | text = 'Error'; 103 | } 104 | 105 | return ` 106 | 107 | ${icon.svg} 108 | ${text} 109 | 110 | `; 111 | } 112 | 113 | return { 114 | createTypeIcon, 115 | createStatusIcon, 116 | createStatusBadge 117 | }; 118 | })(); -------------------------------------------------------------------------------- /src/public/js/ui/thresholds.js: -------------------------------------------------------------------------------- 1 | PulseApp.ui = PulseApp.ui || {}; 2 | 3 | PulseApp.ui.thresholds = (() => { 4 | let thresholdRow = null; 5 | let toggleThresholdsButton = null; 6 | let thresholdBadge = null; 7 | let sliders = {}; 8 | let thresholdSelects = {}; 9 | let isDraggingSlider = false; 10 | 11 | function init() { 12 | thresholdRow = document.getElementById('threshold-slider-row'); 13 | toggleThresholdsButton = document.getElementById('toggle-thresholds-checkbox'); 14 | thresholdBadge = document.getElementById('threshold-count-badge'); 15 | 16 | sliders = { 17 | cpu: document.getElementById('threshold-slider-cpu'), 18 | memory: document.getElementById('threshold-slider-memory'), 19 | disk: document.getElementById('threshold-slider-disk'), 20 | }; 21 | thresholdSelects = { 22 | diskread: document.getElementById('threshold-select-diskread'), 23 | diskwrite: document.getElementById('threshold-select-diskwrite'), 24 | netin: document.getElementById('threshold-select-netin'), 25 | netout: document.getElementById('threshold-select-netout'), 26 | }; 27 | 28 | applyInitialThresholdUI(); 29 | updateThresholdIndicator(); 30 | updateThresholdRowVisibility(); 31 | 32 | if (toggleThresholdsButton) { 33 | toggleThresholdsButton.addEventListener('change', () => { 34 | PulseApp.state.set('isThresholdRowVisible', toggleThresholdsButton.checked); 35 | updateThresholdRowVisibility(); 36 | }); 37 | } else { 38 | console.warn('#toggle-thresholds-checkbox not found.'); 39 | } 40 | 41 | _setupSliderListeners(); 42 | _setupSelectListeners(); 43 | _setupDragEndListeners(); 44 | } 45 | 46 | function applyInitialThresholdUI() { 47 | const thresholdState = PulseApp.state.getThresholdState(); 48 | for (const type in thresholdState) { 49 | if (sliders[type]) { 50 | const sliderElement = sliders[type]; 51 | if (sliderElement) sliderElement.value = thresholdState[type].value; 52 | } else if (thresholdSelects[type]) { 53 | const selectElement = thresholdSelects[type]; 54 | if (selectElement) selectElement.value = thresholdState[type].value; 55 | } 56 | } 57 | } 58 | 59 | function _handleThresholdDragStart(event) { 60 | PulseApp.tooltips.updateSliderTooltip(event.target); 61 | isDraggingSlider = true; 62 | if (PulseApp.ui.dashboard && PulseApp.ui.dashboard.snapshotGuestMetricsForDrag) { 63 | PulseApp.ui.dashboard.snapshotGuestMetricsForDrag(); 64 | } 65 | } 66 | 67 | function _handleThresholdDragEnd() { 68 | if (isDraggingSlider) { 69 | isDraggingSlider = false; 70 | if (PulseApp.ui.dashboard && PulseApp.ui.dashboard.clearGuestMetricSnapshots) { 71 | PulseApp.ui.dashboard.clearGuestMetricSnapshots(); 72 | } 73 | } 74 | } 75 | 76 | function _setupSliderListeners() { 77 | for (const type in sliders) { 78 | const sliderElement = sliders[type]; 79 | if (sliderElement) { 80 | sliderElement.addEventListener('input', (event) => { 81 | const value = event.target.value; 82 | updateThreshold(type, value); 83 | PulseApp.tooltips.updateSliderTooltip(event.target); 84 | }); 85 | sliderElement.addEventListener('mousedown', _handleThresholdDragStart); 86 | sliderElement.addEventListener('touchstart', _handleThresholdDragStart, { passive: true }); 87 | } else { 88 | console.warn(`Slider element not found for type: ${type}`); 89 | } 90 | } 91 | } 92 | 93 | function _setupSelectListeners() { 94 | for (const type in thresholdSelects) { 95 | const selectElement = thresholdSelects[type]; 96 | if (selectElement) { 97 | selectElement.addEventListener('change', (event) => { 98 | const value = event.target.value; 99 | updateThreshold(type, value); 100 | }); 101 | } else { 102 | console.warn(`Select element not found for type: ${type}`); 103 | } 104 | } 105 | } 106 | 107 | function _setupDragEndListeners() { 108 | // Listen globally for drag end events 109 | document.addEventListener('mouseup', _handleThresholdDragEnd); 110 | document.addEventListener('touchend', _handleThresholdDragEnd); 111 | } 112 | 113 | function updateThreshold(type, value) { 114 | PulseApp.state.setThresholdValue(type, value); 115 | 116 | if (PulseApp.ui && PulseApp.ui.dashboard) { 117 | PulseApp.ui.dashboard.updateDashboardTable(); 118 | } else { 119 | console.warn('[Thresholds] PulseApp.ui.dashboard not available for updateDashboardTable'); 120 | } 121 | updateThresholdIndicator(); 122 | } 123 | 124 | function updateThresholdRowVisibility() { 125 | const isVisible = PulseApp.state.get('isThresholdRowVisible'); 126 | if (thresholdRow) { 127 | thresholdRow.classList.toggle('hidden', !isVisible); 128 | if (toggleThresholdsButton) { 129 | // Update checkbox state to match visibility 130 | toggleThresholdsButton.checked = isVisible; 131 | } 132 | } 133 | } 134 | 135 | function _updateThresholdHeaderStyles(thresholdState) { 136 | const mainTableHeader = document.querySelector('#main-table thead'); 137 | if (!mainTableHeader) return 0; // Return 0 active count if header not found 138 | 139 | let activeCount = 0; 140 | const defaultColorClasses = ['text-gray-600', 'dark:text-gray-300']; 141 | const activeColorClasses = ['text-blue-600', 'dark:text-blue-400']; 142 | 143 | for (const type in thresholdState) { 144 | const headerCell = mainTableHeader.querySelector(`th[data-sort="${type}"]`); 145 | if (!headerCell) continue; // Skip if header cell for this type doesn't exist 146 | 147 | if (thresholdState[type].value > 0) { 148 | activeCount++; 149 | headerCell.classList.add('threshold-active-header'); 150 | headerCell.classList.remove(...defaultColorClasses); 151 | headerCell.classList.add(...activeColorClasses); 152 | } else { 153 | headerCell.classList.remove('threshold-active-header'); 154 | headerCell.classList.remove(...activeColorClasses); 155 | headerCell.classList.add(...defaultColorClasses); 156 | } 157 | } 158 | return activeCount; 159 | } 160 | 161 | function updateThresholdIndicator() { 162 | if (!thresholdBadge) return; 163 | 164 | const thresholdState = PulseApp.state.getThresholdState(); 165 | const activeCount = _updateThresholdHeaderStyles(thresholdState); 166 | 167 | if (activeCount > 0) { 168 | thresholdBadge.textContent = activeCount; 169 | thresholdBadge.classList.remove('hidden'); 170 | } else { 171 | thresholdBadge.classList.add('hidden'); 172 | } 173 | } 174 | 175 | function resetThresholds() { 176 | const thresholdState = PulseApp.state.getThresholdState(); 177 | for (const type in thresholdState) { 178 | PulseApp.state.setThresholdValue(type, 0); 179 | if (sliders[type]) { 180 | const sliderElement = sliders[type]; 181 | if (sliderElement) sliderElement.value = 0; 182 | } else if (thresholdSelects[type]) { 183 | const selectElement = thresholdSelects[type]; 184 | if (selectElement) selectElement.value = 0; 185 | } 186 | } 187 | PulseApp.tooltips.hideSliderTooltip(); 188 | PulseApp.state.set('isThresholdRowVisible', false); 189 | updateThresholdRowVisibility(); 190 | updateThresholdIndicator(); 191 | } 192 | 193 | // Getter for dashboard.js to check drag state 194 | function isThresholdDragInProgress() { 195 | return isDraggingSlider; 196 | } 197 | 198 | return { 199 | init, 200 | resetThresholds, 201 | isThresholdDragInProgress 202 | }; 203 | })(); 204 | -------------------------------------------------------------------------------- /src/public/js/virtual-scroll.js: -------------------------------------------------------------------------------- 1 | // Virtual scrolling implementation for large datasets 2 | PulseApp.virtualScroll = (() => { 3 | const ITEM_HEIGHT = 40; // Height of each row in pixels 4 | const BUFFER_SIZE = 5; // Number of items to render outside viewport 5 | const SCROLL_DEBOUNCE = 10; // Debounce scroll events 6 | 7 | class VirtualScroller { 8 | constructor(container, items, rowRenderer) { 9 | this.container = container; 10 | this.items = items; 11 | this.rowRenderer = rowRenderer; 12 | this.itemHeight = ITEM_HEIGHT; 13 | this.bufferSize = BUFFER_SIZE; 14 | 15 | this.scrollTop = 0; 16 | this.containerHeight = 0; 17 | this.visibleStart = 0; 18 | this.visibleEnd = 0; 19 | 20 | this.scrollHandler = null; 21 | this.resizeObserver = null; 22 | 23 | this.init(); 24 | } 25 | 26 | init() { 27 | // Create scroll container structure 28 | this.viewport = document.createElement('div'); 29 | this.viewport.className = 'virtual-scroll-viewport'; 30 | this.viewport.style.height = '100%'; 31 | this.viewport.style.overflowY = 'auto'; 32 | this.viewport.style.position = 'relative'; 33 | 34 | this.spacer = document.createElement('div'); 35 | this.spacer.className = 'virtual-scroll-spacer'; 36 | this.spacer.style.height = `${this.items.length * this.itemHeight}px`; 37 | 38 | this.content = document.createElement('div'); 39 | this.content.className = 'virtual-scroll-content'; 40 | this.content.style.position = 'absolute'; 41 | this.content.style.top = '0'; 42 | this.content.style.left = '0'; 43 | this.content.style.right = '0'; 44 | 45 | this.viewport.appendChild(this.spacer); 46 | this.viewport.appendChild(this.content); 47 | 48 | // Clear container and add viewport 49 | this.container.innerHTML = ''; 50 | this.container.appendChild(this.viewport); 51 | 52 | // Set up event listeners 53 | this.scrollHandler = this.debounce(() => this.handleScroll(), SCROLL_DEBOUNCE); 54 | this.viewport.addEventListener('scroll', this.scrollHandler); 55 | 56 | // Set up resize observer 57 | this.resizeObserver = new ResizeObserver(() => this.handleResize()); 58 | this.resizeObserver.observe(this.viewport); 59 | 60 | // Initial render 61 | this.handleResize(); 62 | } 63 | 64 | handleScroll() { 65 | this.scrollTop = this.viewport.scrollTop; 66 | this.updateVisibleRange(); 67 | this.render(); 68 | } 69 | 70 | handleResize() { 71 | this.containerHeight = this.viewport.clientHeight; 72 | this.updateVisibleRange(); 73 | this.render(); 74 | } 75 | 76 | updateVisibleRange() { 77 | const scrollTop = this.scrollTop; 78 | const visibleItems = Math.ceil(this.containerHeight / this.itemHeight); 79 | 80 | this.visibleStart = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferSize); 81 | this.visibleEnd = Math.min( 82 | this.items.length, 83 | Math.ceil((scrollTop + this.containerHeight) / this.itemHeight) + this.bufferSize 84 | ); 85 | } 86 | 87 | render() { 88 | const fragment = document.createDocumentFragment(); 89 | const visibleItems = this.items.slice(this.visibleStart, this.visibleEnd); 90 | 91 | // Create rows for visible items 92 | visibleItems.forEach((item, index) => { 93 | const actualIndex = this.visibleStart + index; 94 | const row = this.rowRenderer(item, actualIndex); 95 | 96 | if (row) { 97 | // Position the row 98 | row.style.position = 'absolute'; 99 | row.style.top = `${actualIndex * this.itemHeight}px`; 100 | row.style.left = '0'; 101 | row.style.right = '0'; 102 | row.style.height = `${this.itemHeight}px`; 103 | fragment.appendChild(row); 104 | } 105 | }); 106 | 107 | // Clear content and add new rows 108 | this.content.innerHTML = ''; 109 | this.content.appendChild(fragment); 110 | } 111 | 112 | updateItems(newItems) { 113 | this.items = newItems; 114 | this.spacer.style.height = `${this.items.length * this.itemHeight}px`; 115 | this.updateVisibleRange(); 116 | this.render(); 117 | } 118 | 119 | scrollToItem(index) { 120 | const scrollTop = index * this.itemHeight; 121 | this.viewport.scrollTop = scrollTop; 122 | } 123 | 124 | destroy() { 125 | if (this.scrollHandler) { 126 | this.viewport.removeEventListener('scroll', this.scrollHandler); 127 | } 128 | if (this.resizeObserver) { 129 | this.resizeObserver.disconnect(); 130 | } 131 | this.container.innerHTML = ''; 132 | } 133 | 134 | debounce(func, wait) { 135 | let timeout; 136 | return function executedFunction(...args) { 137 | const later = () => { 138 | clearTimeout(timeout); 139 | func(...args); 140 | }; 141 | clearTimeout(timeout); 142 | timeout = setTimeout(later, wait); 143 | }; 144 | } 145 | } 146 | 147 | function createVirtualScroller(container, items, rowRenderer) { 148 | return new VirtualScroller(container, items, rowRenderer); 149 | } 150 | 151 | return { 152 | createVirtualScroller 153 | }; 154 | })(); -------------------------------------------------------------------------------- /src/public/js/wcag-colors.js: -------------------------------------------------------------------------------- 1 | // WCAG-compliant color replacements 2 | PulseApp.wcagColors = (() => { 3 | // Map of old colors to WCAG-compliant replacements 4 | // These meet WCAG AA standards (4.5:1 for normal text, 3:1 for large text) 5 | const colorMap = { 6 | // Gray scale improvements for better contrast 7 | 'text-gray-400': 'text-gray-600', // Light mode 8 | 'dark:text-gray-400': 'dark:text-gray-300', // Dark mode 9 | 'text-gray-500': 'text-gray-700', 10 | 'dark:text-gray-500': 'dark:text-gray-300', 11 | 'text-gray-600': 'text-gray-700', 12 | 'dark:text-gray-600': 'dark:text-gray-300', 13 | 14 | // Status colors with better contrast 15 | 'text-green-500': 'text-green-700', 16 | 'dark:text-green-500': 'dark:text-green-400', 17 | 'text-red-500': 'text-red-700', 18 | 'dark:text-red-500': 'dark:text-red-400', 19 | 'text-yellow-500': 'text-yellow-700', 20 | 'dark:text-yellow-500': 'dark:text-yellow-400', 21 | 'text-blue-500': 'text-blue-700', 22 | 'dark:text-blue-500': 'dark:text-blue-400', 23 | 24 | // Background adjustments for better contrast 25 | 'bg-gray-100': 'bg-gray-50', 26 | 'dark:bg-gray-800': 'dark:bg-gray-900', 27 | 28 | // Specific problematic combinations 29 | 'text-gray-400 dark:text-gray-500': 'text-gray-600 dark:text-gray-300', 30 | 'text-gray-500 dark:text-gray-400': 'text-gray-700 dark:text-gray-300', 31 | 'text-gray-600 dark:text-gray-400': 'text-gray-700 dark:text-gray-300' 32 | }; 33 | 34 | function applyWCAGColors() { 35 | // This would be called during initialization to update CSS classes 36 | } 37 | 38 | function getWCAGColor(originalClass) { 39 | return colorMap[originalClass] || originalClass; 40 | } 41 | 42 | return { 43 | applyWCAGColors, 44 | getWCAGColor 45 | }; 46 | })(); -------------------------------------------------------------------------------- /src/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | Pulse Logo 3 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/public/logos/pulse-logo-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcourtman/Pulse/6fbc4e0422073835a0cc0aad45cb52aec209cd2a/src/public/logos/pulse-logo-256x256.png -------------------------------------------------------------------------------- /src/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './src/public/**/*.html', 6 | './src/public/**/*.js', 7 | ], 8 | safelist: [ 9 | 'opacity-60', 10 | { 11 | pattern: /^(bg|text)-(red|yellow|green|blue)-(100|200|300|400|500|600|700|800|900)(\/50)?$/, 12 | }, 13 | 'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4', 14 | 'sm:grid-cols-1', 'sm:grid-cols-2', 'sm:grid-cols-3', 'sm:grid-cols-4', 15 | 'md:grid-cols-1', 'md:grid-cols-2', 'md:grid-cols-3', 'md:grid-cols-4', 16 | 'lg:grid-cols-1', 'lg:grid-cols-2', 'lg:grid-cols-3', 'lg:grid-cols-4', 17 | 'xl:grid-cols-1', 'xl:grid-cols-2', 'xl:grid-cols-3', 'xl:grid-cols-4', 18 | 'vm-icon', 19 | 'ct-icon', 20 | 'hidden', 21 | 'inline', 22 | 'sm:hidden', 23 | 'sm:inline', 24 | 'truncate', 25 | 'px-1', 26 | 'table-cell', 27 | 'sm:table-cell', 28 | 'md:table-cell', 29 | 'lg:table-cell', 30 | ], 31 | theme: { 32 | extend: {}, 33 | scrollbar: theme => ({ 34 | DEFAULT: { 35 | size: theme('spacing.3'), 36 | track: { 37 | background: theme('colors.gray.100'), 38 | darkBackground: theme('colors.neutral.700'), 39 | }, 40 | thumb: { 41 | background: theme('colors.gray.400'), 42 | darkBackground: theme('colors.neutral.500'), 43 | borderRadius: theme('borderRadius.full'), 44 | }, 45 | hover: { 46 | background: theme('colors.gray.500'), 47 | darkBackground: theme('colors.neutral.400'), 48 | }, 49 | }, 50 | }), 51 | }, 52 | plugins: [ 53 | require('@gradin/tailwindcss-scrollbar'), 54 | ], 55 | } 56 | 57 | --------------------------------------------------------------------------------