Merge pull request #46 from rcourtman/simplified-rewrite

feat: Replace codebase entirely with simplified version
This commit is contained in:
rcourtman
2025-04-20 19:30:09 +01:00
committed by GitHub
197 changed files with 5456 additions and 42600 deletions
-107
View File
@@ -1,107 +0,0 @@
# Pulse Environment Configuration
# Copy this file to .env to configure your environment
# === Basic Configuration ===
# Set to 'production' for normal use or 'development' for testing
NODE_ENV=production
# Log level: 'error', 'warn', 'info', 'debug'
LOG_LEVEL=info
# Port for the application server
PORT=7654
# === Proxmox Configuration ===
# Replace with your Proxmox VE node details
# Node 1
PROXMOX_NODE_1_NAME=Proxmox Node 1
PROXMOX_NODE_1_HOST=https://proxmox.local:8006
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-token-secret
# Node 2 (optional)
# PROXMOX_NODE_2_NAME=Proxmox Node 2
# PROXMOX_NODE_2_HOST=https://proxmox2.local:8006
# PROXMOX_NODE_2_TOKEN_ID=root@pam!pulse
# PROXMOX_NODE_2_TOKEN_SECRET=your-token-secret
# === SSL Configuration ===
# SECURITY WARNING:
# - For development or internal networks, you can set these to true/0
# - For production, set these to false/1 and ensure proper SSL certificates are installed
# - Setting these to true/0 disables SSL certificate validation and is not secure for production
IGNORE_SSL_ERRORS=true
NODE_TLS_REJECT_UNAUTHORIZED=0
# === Application Configuration ===
# How many minutes of metrics history to keep in memory
METRICS_HISTORY_MINUTES=30
# Polling intervals (in milliseconds)
NODE_POLLING_INTERVAL_MS=15000
EVENT_POLLING_INTERVAL_MS=5000
# API rate limiting settings
API_RATE_LIMIT_MS=2000
API_TIMEOUT_MS=90000
API_RETRY_DELAY_MS=10000
# Maximum realistic network rate in MB/s (default: 125 MB/s = 1 Gbps)
# Increase this value for faster networks:
# - 1250 for 10 Gbps networks
# - 3125 for 25 Gbps networks
# - 12500 for 100 Gbps networks
METRICS_MAX_REALISTIC_RATE=125
# Enable developer tools (set to true only for debugging production issues)
ENABLE_DEV_TOOLS=false
# === Performance Tuning ===
# Uncomment to enable events-only mode (reduces resource usage)
# This will only query for events without constantly fetching full resource stats
# EVENTS_ONLY_MODE=true
# === Cluster Configuration ===
# Pulse automatically detects if your Proxmox nodes are part of a cluster
# Set to 'false' to disable automatic cluster detection
# PROXMOX_AUTO_DETECT_CLUSTER=true
# Set to 'false' to disable cluster mode even if a cluster is detected
# PROXMOX_CLUSTER_MODE=true
# Custom name for your cluster (defaults to the detected cluster name or 'proxmox-cluster')
# PROXMOX_CLUSTER_NAME=my-cluster
# === Testing Options ===
# Set to 'true' to use mock data instead of connecting to a real Proxmox server
# For production: false
# For development with mock data: true
USE_MOCK_DATA=false
MOCK_DATA_ENABLED=false
# === Mock Cluster Settings ===
# Only used when mock data is enabled
# Set to 'false' to disable mock cluster mode
# MOCK_CLUSTER_ENABLED=true
# Custom name for the mock cluster
# MOCK_CLUSTER_NAME=mock-cluster
# === Docker Configuration ===
# Docker container name
CONTAINER_NAME=pulse
# Which Dockerfile to use (you shouldn't need to change this)
# For production: docker/Dockerfile
# For development: docker/Dockerfile.dev
DOCKERFILE=docker/Dockerfile
# Container restart policy
RESTART_POLICY=unless-stopped
# Set to 'true' to run in test mode (no actual changes will be made)
DRY_RUN=false
# === Developer Options ===
# Uncomment these lines only if you're developing or modifying the application
# DEV_SRC_MOUNT=./src:/app/src
# DEV_FRONTEND_SRC_MOUNT=./frontend/src:/app/frontend/src
# DEV_FRONTEND_PUBLIC_MOUNT=./frontend/public:/app/frontend/public
# DEV_FRONTEND_INDEX_MOUNT=./frontend/index.html:/app/frontend/index.html
# DEV_FRONTEND_CONFIG_MOUNT=./frontend/vite.config.js:/app/frontend/vite.config.js
# DEV_SCRIPTS_MOUNT=./scripts:/app/scripts
# DEV_ENV_MOUNT=./environments:/app/environments
# === Frontend Configuration ===
# NOTE: It's recommended to NOT set VITE_API_URL as it can cause WebSocket connection issues
# The application will automatically use the correct URL based on how you access it
# VITE_API_URL=http://your-server-ip:7654
-12
View File
@@ -1,12 +0,0 @@
# These are supported funding model platforms
ko_fi: rcourtman
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug Report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
## Bug Description
A clear and concise description of what the bug is.
## Steps To Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
What actually happened instead.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- OS: [e.g. Ubuntu 22.04, Windows 11, macOS 13.0]
- Browser: [e.g. Chrome 108, Firefox 106]
- Proxmox VE Version: [e.g. 7.3-3]
- Pulse Version: [e.g. 1.2.1]
- Deployment Method: [e.g. Docker, Docker Compose]
## Additional Context
Add any other context about the problem here. For example:
- Does it happen consistently or intermittently?
- Are there any error messages in the browser console or server logs?
- Did this work in a previous version?
-23
View File
@@ -1,23 +0,0 @@
---
name: Feature Request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Problem Statement
A clear and concise description of what problem this feature would solve.
Example: I'm always frustrated when [...]
## Proposed Solution
A clear and concise description of what you want to happen.
## Alternative Solutions
A clear and concise description of any alternative solutions or features you've considered.
## Example Implementation
If you can, provide examples of how this feature works in other applications or how it might be implemented.
## Additional Context
Add any other context, screenshots, or mockups about the feature request here.
@@ -1,28 +0,0 @@
## Description
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. -->
Fixes # (issue)
## Type of change
<!-- Please delete options that are not relevant. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
-15
View File
@@ -1,15 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
versioning-strategy: auto
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
versioning-strategy: auto
-24
View File
@@ -1,24 +0,0 @@
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
+12 -48
View File
@@ -1,57 +1,21 @@
# Dependencies
# Node dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
server/node_modules/
# Environment variables
.env
.env.local
.env.*.local
.env.backup
# Build output
dist/
build/
server/.env
# Logs
logs/
*.log
socket-debug.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Reports
reports/
# But include the mock test report for documentation
!reports/proxmox-api-test-*.json
# Build outputs
build/
dist/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Test coverage
coverage/ .cursor/
# Maintainer-only scripts
scripts/release.sh
# Release process
.release-prompt.md
.commit-prompt.md
docs/RELEASE_PROCESS.md
# Development tools
/tmp/pulse-*.log
# Personal checklist
VERSION_UPDATE_CHECKLIST.md
docker-compose.dev.yml
# Issue responses directory
.draft-notes/
# Optional files
.DS_Store
-228
View File
@@ -1,228 +0,0 @@
# Changelog
## [Unreleased]
## [1.6.4] - 2024-03-14
### Added
- Added DEMO MODE indicator when running with mock data
### Fixed
- Fixed Demo Mode banner and environment detection for mock data
- Fixed type column sorting to properly alternate between CT and VM
- Fixed sorting for status and type columns with special case handling
- Improved mock data with more stopped guests and proper uptime values
- Enhanced mock data with more dynamic changes and patterns
- Fixed Docker development environment by preventing duplicate .dev extension in Dockerfile path
### Documentation
- Updated README with clear all-in-one installation command and Node.js version requirements
- Updated README.md and GETTING-STARTED.md with correct startup procedures and commands
- Updated version link to point to specific release tag
## [1.6.3] - 2024-03-13
### Security
- Fixed high severity vulnerability (GHSA-v2mw-5mch-w8c5) by removing unused jspdf and jspdf-autotable packages
- Updated Babel dependencies to fix moderate severity vulnerability (GHSA-968p-4wvh-cqc8)
## [1.6.2] - 2024-03-13
### Added
- Improved application launcher UI with a more user-friendly menu
### Fixed
- Reduced spacing between header and content for better layout
- Fixed search highlighting to only highlight relevant columns with prefix searches
- Improved screenshot quality and automation workflow
- Updated Docker Compose environment variables to match .env.example format
## [1.6.1] - 2024-03-12
### Fixed
- Reverted to the original environment variable naming format (PROXMOX_NODE_1_NAME, etc.)
- Removed the new environment variable format introduced in v1.6.0
- Improved backward compatibility for users upgrading from earlier versions
## [1.6.0] - 2024-03-12
### Added
- New one-line installation command: `git clone https://github.com/rcourtman/pulse.git && cd pulse && npm run install:pulse`
- Interactive installation script (`scripts/install.sh`) for guided setup
- New logging system with advanced filtering and real-time monitoring
- Comprehensive logging documentation in `docs/logging.md`
- New utility scripts for troubleshooting and configuration
- Getting Started guide (`GETTING-STARTED.md`) for new users
- Improved metrics service with better rate detection and capping
- New npm scripts for common operations (logs, status, restart, stop, cleanup)
- Proxmox cluster support: Added automatic detection and handling of Proxmox clusters
- System now automatically detects if nodes are part of a cluster without manual configuration
- Manual configuration via PROXMOX_CLUSTER_MODE is still supported but optional
- New configuration options: PROXMOX_CLUSTER_MODE and PROXMOX_CLUSTER_NAME (optional)
- Prevents duplicate VMs/CTs from appearing when multiple nodes from the same cluster are configured
- Mock cluster simulation for development and testing
- New mock data environment variables: MOCK_CLUSTER_ENABLED and MOCK_CLUSTER_NAME (optional)
- Updated documentation for mock data and cluster functionality
- Simplified environment configuration with a single .env file
- Improved development and production scripts for better cross-platform support
- New start-prod.sh and start-prod.bat scripts for production deployment
- Comprehensive mock data documentation in docs/MOCK_DATA.md
- Enhanced Docker development environment with detached mode and cleanup commands
- Performance tuning documentation and optimized default settings:
- Increased polling intervals to reduce Proxmox server load (NODE_POLLING_INTERVAL_MS, EVENT_POLLING_INTERVAL_MS)
- Added API rate limiting settings (API_RATE_LIMIT_MS, API_TIMEOUT_MS, API_RETRY_DELAY_MS)
- Reduced memory usage with optimized metrics history (METRICS_HISTORY_MINUTES)
- New performance tuning guide in documentation
- Animated pulsing logo for better visual feedback
- Detailed WebSocket troubleshooting guide for connection issues
- Enhanced CPU modeling in simulation mode for more realistic testing
### Changed
- Simplified environment configuration with a single `.env` file instead of multiple environment-specific files
- Improved Docker configuration with dynamic environment variables
- **IMPORTANT**: Default polling intervals have been increased to reduce load on Proxmox servers
- NODE_POLLING_INTERVAL_MS: 1000ms → 15000ms
- EVENT_POLLING_INTERVAL_MS: 1000ms → 5000ms
- METRICS_HISTORY_MINUTES: 60 → 30
- Users upgrading from previous versions should update their environment settings for optimal performance
- Restructured project organization for better maintainability
- Updated container name from `pulse-app` to `pulse`
- Moved Docker files to `docker/` directory
- Enhanced metrics service to handle unrealistic network rates
- Updated documentation to reflect new simplified configuration
- Improved WebSocket troubleshooting with new diagnostic tools
- Combine system type and visibility filters, improve UI design
- Refactor NetworkDisplay component into smaller, more manageable files with custom hooks and components
- Optimize column widths for space conservation in network table
- Refactor proxmox-client.ts into smaller modules for better maintainability
- Enhance network table UI with improved filtering and search functionality
- Improve space efficiency for important metric columns
- Standardize styling across all dropdown menus and popovers
- Move status column to leftmost position for better organization
- Center status icons for better visual alignment
- Replace status text with icon-only display to save space
- Updated README with new screenshots and improved documentation
- Enhance export functionality with additional fields (Type, ID, Uptime) in NetworkDisplay
- Clarify node vs guest counts in dropdown by adding descriptive labels
- Improved error handling in Proxmox API client
- Enhanced network metrics stability with moving average calculations
### Removed
- Separate environment files (`.env.development`, `.env.production`)
- Redundant Docker Compose files (merged into a single configurable file)
- Legacy startup scripts (replaced with the new launcher system)
## [1.5.4] - 2024-03-26
### Fixed
- Fixed network rate calculation to properly handle Proxmox cumulative counters, resolving inaccurate network speed reporting
## [1.5.3] - 2025-03-10
### Security
- Improved Docker networking security by making bridge networking (default) work properly with WebSockets
- Removed host network mode as the default recommendation for better container isolation
- Updated documentation to prioritize secure networking options
### Fixed
- Enhanced WebSocket connection handling to work correctly in bridge network mode
- Improved hostname resolution for WebSocket connections
## [1.5.2] - 2025-03-01
### Fixed
- WebSocket connection issues in Docker environments by using the browser's hostname instead of hardcoded localhost
- Updated docker-compose.yml to use host network mode by default to fix connection issues
- Added comprehensive troubleshooting guide for WebSocket connection problems
## [1.5.1] - 2025-03-10
### Added
- Improved error handling for WebSocket connections
- Better logging for connection issues
### Fixed
- Various minor UI bugs
- Performance improvements for metric collection
## [1.5.0] - 2024-03-10
### Added
- New animated pulse logo implementation
- Cross-platform development environment with hot-reloading
- Cross-platform cleanup script for development processes
### Fixed
- Fixed Vite port incrementing issues
- Fixed logo asset inclusion in Docker build
- Improved Docker development environment setup
### Changed
- Improved Docker build and release process
- Enhanced Docker Compose setup for users and developers
- Updated development workflow documentation
## [1.4.1] - 2024-03-15
### Fixed
- Fixed static file serving in production Docker container
## [1.4.0] - 2024-03-15
### Added
- Improved favicon support with multiple sizes and formats
- Detailed Docker buildx setup and troubleshooting documentation
- Comprehensive release process documentation
- Improved changelog generation process
### Changed
- Updated dependencies to latest major versions (@types/express@5, node-fetch@3, @types/react-dom@19)
- Simplified release process and removed automated script references
- Consolidated release documentation into single comprehensive guide
- Improved TypeScript type definitions in API routes
### Fixed
- TypeScript build errors in API route handlers
- Updated package-lock.json versions
## [1.3.1] - 2025-03-10
### Security
- Update axios to version 1.8.2 to address SSRF and Credential Leakage vulnerability (GHSA-jr5f-v2jv-69x6)
## [1.3.0] - 2024-03-09
### Added
- Dashboard screenshot to README
- Ko-fi support button and Support section
- Code of Conduct based on Contributor Covenant
- GitHub templates and developer documentation
- Development architecture explanation to README
- start-dev.sh script to repository
### Changed
- Combine system type and visibility filters, improve UI design
- Refactor NetworkDisplay component into smaller, more manageable files with custom hooks and components
- Optimize column widths for space conservation in network table
- Refactor proxmox-client.ts into smaller modules for better maintainability
- Enhance network table UI with improved filtering and search functionality
- Improve space efficiency for important metric columns
- Standardize styling across all dropdown menus and popovers
- Move status column to leftmost position for better organization
- Center status icons for better visual alignment
- Replace status text with icon-only display to save space
- Update README with new screenshots and improved documentation
- Enhance export functionality with additional fields (Type, ID, Uptime) in NetworkDisplay
- Clarify node vs guest counts in dropdown by adding descriptive labels
### Fixed
- Fix search filtering for type terms and optimize type column width
- Update vite.config.js to use IPv4 (127.0.0.1) instead of IPv6 (::1) for WebSocket connections
- Fix transparent table headers in CT/VM list by adding solid background and border
- Fix column visibility issues in network table
- Improve dropdown behavior and styling
- Ensure 'No Matching Systems' message spans full table width
## [1.2.1] - 2024-03-04
Initial versioned release.
-125
View File
@@ -1,125 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainers responsible for enforcement at
courtmanr@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All project maintainers are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from project maintainers, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
-82
View File
@@ -1,82 +0,0 @@
# Contributing to Pulse for Proxmox VE
Thank you for considering contributing to Pulse for Proxmox VE! This document outlines the process for contributing to the project.
## Code of Conduct
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
## How Can I Contribute?
### Reporting Bugs
This section guides you through submitting a bug report. Following these guidelines helps maintainers understand your report, reproduce the behavior, and find related reports.
Before creating bug reports, please check [the issue list](https://github.com/rcourtman/pulse/issues) as you might find that you don't need to create one. When you are creating a bug report, please include as many details as possible:
* **Use a clear and descriptive title** for the issue to identify the problem.
* **Describe the exact steps which reproduce the problem** in as many details as possible.
* **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples.
* **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior.
* **Explain which behavior you expected to see instead and why.**
* **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem.
* **If the problem is related to performance or memory**, include a CPU profile capture with your report.
* **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened.
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion, including completely new features and minor improvements to existing functionality.
* **Use a clear and descriptive title** for the issue to identify the suggestion.
* **Provide a step-by-step description of the suggested enhancement** in as many details as possible.
* **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples.
* **Describe the current behavior** and **explain which behavior you expected to see instead** and why.
* **Include screenshots and animated GIFs** which help you demonstrate the steps or point out the part of Pulse for Proxmox VE which the suggestion is related to.
* **Explain why this enhancement would be useful** to most Pulse for Proxmox VE users.
* **List some other applications where this enhancement exists.**
### Pull Requests
* Fill in the required template
* Do not include issue numbers in the PR title
* Include screenshots and animated GIFs in your pull request whenever possible
* Follow the JavaScript/TypeScript styleguide
* Include adequate tests
* Document new code
* End all files with a newline
## Development Process
### Setting Up Development Environment
1. Fork the repository
2. Clone your fork: `git clone https://github.com/your-username/pulse.git`
3. Install dependencies: `npm install`
4. Install frontend dependencies: `cd frontend && npm install`
5. Create a `.env` file based on the `.env.example` file
6. Run the application: `npm run pulse`
### Coding Style
* Use 2 spaces for indentation
* Use camelCase for variables and functions
* Use PascalCase for classes and components
* Use UPPERCASE for constants
* Add comments for complex logic
### Testing
* Write tests for new features
* Ensure all tests pass before submitting a pull request
* Run tests with `npm test`
### Commit Messages
* Use the present tense ("Add feature" not "Added feature")
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
* Limit the first line to 72 characters or less
* Reference issues and pull requests liberally after the first line
## License
By contributing to Pulse for Proxmox VE, you agree that your contributions will be licensed under the project's [MIT License](LICENSE).
+31
View File
@@ -0,0 +1,31 @@
# Use an official Node runtime as a parent image
# Choose a version compatible with your application (e.g., LTS version like 18 or 20)
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json for root dependencies
COPY package*.json ./
# Install root dependencies
RUN npm install
# Copy server package.json and package-lock.json
COPY server/package*.json ./server/
# Install server dependencies
RUN cd server && npm install
# Copy the rest of the application code
# Copy server code first
COPY server/ ./server/
# Copy public directory if it's used by the server (adjust if needed)
COPY public/ ./public/
# Application listens on port 7655 by default (as per .env.example)
EXPOSE 7655
# Define the command to run the app using the start script from root package.json
# This script should handle changing into the server directory
CMD [ "npm", "run", "start" ]
-207
View File
@@ -1,207 +0,0 @@
# Getting Started with Pulse for Proxmox VE
This guide will help you set up and run Pulse for Proxmox VE on your system.
## Prerequisites
- [Node.js](https://nodejs.org/) (v14 or higher)
- [npm](https://www.npmjs.com/) (comes with Node.js)
- [Docker](https://www.docker.com/) (recommended)
- A Proxmox VE server with API access
- A Proxmox API token with appropriate permissions
## Quick Setup
1. **Clone the repository**:
```bash
git clone https://github.com/rcourtman/pulse.git
cd pulse
```
2. **Start the development environment**:
On Linux/macOS:
```bash
./scripts/start-dev.sh
```
On Windows:
```bash
scripts\start-dev.bat
```
This is the quickest way to get started with Pulse in development mode with mock data.
3. **Create a Proxmox API token** (if you want to use real data):
- Follow the instructions in the [Creating a Proxmox API Token](README.md#creating-a-proxmox-api-token) section of the README
- You'll need this token to connect to your Proxmox server
4. **Configure your environment**:
- Copy the example environment file: `cp .env.example .env`
- Edit this file to add your Proxmox server details and API token
5. **Access the dashboard**:
- Open your browser and navigate to http://localhost:3000
- You should see the Pulse dashboard with your Proxmox data (or mock data if using development mode)
## Environment Configuration
Create a `.env` file in the project root with your Proxmox details. Here's the correct structure:
```bash
# === Basic Configuration ===
NODE_ENV=production
LOG_LEVEL=info
PORT=7654
# === Proxmox Configuration ===
# Node 1
PROXMOX_NODE_1_NAME=Proxmox Node 1
PROXMOX_NODE_1_HOST=https://proxmox.local:8006
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-token-secret
# Node 2 (optional)
# PROXMOX_NODE_2_NAME=Proxmox Node 2
# PROXMOX_NODE_2_HOST=https://proxmox2.local:8006
# PROXMOX_NODE_2_TOKEN_ID=root@pam!pulse
# PROXMOX_NODE_2_TOKEN_SECRET=your-token-secret
# === SSL Configuration ===
# For self-signed certificates or development
IGNORE_SSL_ERRORS=true
NODE_TLS_REJECT_UNAUTHORIZED=0
PROXMOX_REJECT_UNAUTHORIZED=false
HTTPS_REJECT_UNAUTHORIZED=false
PROXMOX_INSECURE=true
PROXMOX_VERIFY_SSL=false
# === Testing Options ===
# Set to 'true' to use mock data instead of connecting to a real Proxmox server
USE_MOCK_DATA=false
MOCK_DATA_ENABLED=false
# === Cluster Configuration ===
PROXMOX_AUTO_DETECT_CLUSTER=true
PROXMOX_CLUSTER_MODE=false
# === Performance Tuning ===
METRICS_HISTORY_MINUTES=30
NODE_POLLING_INTERVAL_MS=15000
EVENT_POLLING_INTERVAL_MS=5000
API_RATE_LIMIT_MS=2000
API_TIMEOUT_MS=90000
API_RETRY_DELAY_MS=10000
```
> ⚠️ **Security Note**: The `.env` file contains sensitive information. Do not commit it to your repository.
## Running Different Environments
Pulse can be run in different modes depending on your needs:
### Development Mode with Mock Data (Recommended for Testing)
The quickest way to start Pulse in development mode with mock data:
```bash
# On Linux/macOS
./scripts/start-dev.sh
# On Windows
scripts\start-dev.bat
```
This starts Pulse in development mode with hot reloading for both frontend and backend, using mock data so you don't need a real Proxmox server.
### Development Mode with Real Data
To use real Proxmox data in development mode:
1. Edit your `.env` file and set:
```
USE_MOCK_DATA=false
MOCK_DATA_ENABLED=false
```
2. Run the development script:
```bash
# On Linux/macOS
./scripts/start-dev.sh
# On Windows
scripts\start-dev.bat
```
### Production Mode
```bash
# Local production mode
npm run prod
# Docker production mode
npm run prod:docker
```
This starts Pulse in production mode, optimized for performance, using your real Proxmox server.
## Performance Tuning
If you notice that Pulse is consuming too many resources or overwhelming your Proxmox server, you can adjust several settings in your `.env` file:
### Polling Intervals
- `NODE_POLLING_INTERVAL_MS`: How often to poll for node status (recommended: 15000ms)
- `EVENT_POLLING_INTERVAL_MS`: How often to poll for events (recommended: 5000ms)
### Metrics Storage
- `METRICS_HISTORY_MINUTES`: How many minutes of metrics to keep in memory (recommended: 30)
### API Rate Limiting
- `API_RATE_LIMIT_MS`: Minimum time between API requests (recommended: 2000ms)
- `API_TIMEOUT_MS`: Timeout for API requests (recommended: 90000ms)
- `API_RETRY_DELAY_MS`: Delay before retrying failed requests (recommended: 10000ms)
### Cluster Settings
If not using cluster mode, you can disable these features to reduce API calls:
- `PROXMOX_CLUSTER_MODE=false`
- `PROXMOX_AUTO_DETECT_CLUSTER=false`
## Troubleshooting
If you encounter issues:
1. **Check your environment configuration**:
- Verify that your Proxmox server is accessible
- Ensure your API token has the correct permissions
- Check that your `.env` file has the correct values
2. **Try development mode with mock data**:
```bash
./scripts/start-dev.sh
```
This will help verify that the application works without needing a Proxmox server.
3. **Check the Docker logs**:
```bash
docker logs pulse
```
Or use the standard Docker log viewing commands to see what's happening.
4. **Stop running containers**:
```bash
npm run stop
```
5. **Clean up and start fresh**:
```bash
npm run cleanup
npm run prod:docker
```
## Next Steps
- Explore the dashboard and familiarize yourself with the interface
- Check out the [README](README.md) for more detailed information
- Consider setting up Pulse as a service for continuous monitoring
For more advanced configuration options, see the [Advanced Configuration](README.md#-advanced-configuration) section in the README.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Richard Courtman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+116 -603
View File
@@ -1,655 +1,168 @@
# <img src="public/logos/pulse-logo-256x256.png" alt="Pulse Logo" width="32" height="32" style="vertical-align: middle"> Pulse for Proxmox VE
# <img src="public/logos/pulse-logo-256x256.png" alt="Pulse Logo" width="32" height="32" style="vertical-align: middle"> Pulse for Proxmox VE (Simplified)
A lightweight, responsive monitoring application for Proxmox VE that displays real-time metrics for CPU, memory, network, and disk usage across multiple nodes.
![Pulse Dashboard for Proxmox VE](docs/images/dashboard.png)
## ⚡ Quick Installation
> **Prerequisites**: Node.js v20.0.0 or higher is required!
```bash
# All-in-one command to clone repo, install dependencies, and start development server
git clone https://github.com/rcourtman/pulse.git && cd pulse && npm install && cd frontend && npm install && cd .. && ./scripts/start-dev.sh
```
> **Note:** The above command will install all dependencies and start Pulse in development mode with mock data. For real Proxmox data, you'll need a Proxmox API token. See the [Creating a Proxmox API Token](#creating-a-proxmox-api-token) section below.
A lightweight monitoring application for Proxmox VE that displays real-time status for VMs and containers via a simple web interface.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman)
## 📋 Table of Contents
- [Quick Start](#-quick-start)
- [Getting Started Guide](GETTING-STARTED.md)
- [Project Organization](#-project-organization)
- [Configuration](#-configuration)
- [Common Commands](#-common-commands)
- [Docker Configuration](#-docker-configuration)
- [Configuration](#-configuration)
- [Environment Variables](#environment-variables)
- [Creating a Proxmox API Token](#creating-a-proxmox-api-token)
- [Required Permissions](#required-permissions)
- [Installation](#-installation)
- [Running the Application](#-running-the-application)
- [Running the Application (Node.js)](#-running-the-application-nodejs)
- [Running with Docker Compose](#-running-with-docker-compose)
- [Features](#-features)
- [Troubleshooting](#-troubleshooting)
- [WebSocket Connection Issues](#websocket-connection-issues)
- [Logging and Debugging](docs/logging.md)
- [Advanced Configuration](#-advanced-configuration)
- [Development](#-development)
- [Development Architecture](#development-architecture)
- [Developer Documentation](#developer-documentation)
- [System Requirements](#-system-requirements)
- [Version Information](#-version-information)
- [Contributing](#-contributing)
- [Support](#-support)
- [License](#-license)
## 🚀 Quick Start
### Development Mode (Quickest Way to Start)
The easiest way to get started with Pulse is using the development scripts:
1. **Install dependencies** (requires Node.js v20.0.0+):
```bash
# Install main project dependencies
npm install
# Install frontend dependencies
cd frontend && npm install && cd ..
```
2. **Start the development server**:
On Linux/macOS:
```bash
./scripts/start-dev.sh
```
On Windows:
```bash
scripts\start-dev.bat
```
This will start Pulse in development mode with mock data so you don't need a real Proxmox server.
### Production Mode with Real Proxmox Data
1. Create a Proxmox API token:
- See the [Creating a Proxmox API Token](#creating-a-proxmox-api-token) section below
- You'll need this token to connect to your Proxmox server
2. Configure your environment:
- Copy the example environment file: `cp .env.example .env`
- Edit this file to add your Proxmox server details and API token
3. Start the application:
```bash
npm run prod:docker
```
4. Access the dashboard at http://localhost:7654
**New users:** Check out our [Getting Started Guide](GETTING-STARTED.md) for a step-by-step walkthrough.
## 📂 Project Organization
The project has been simplified and organized for ease of use:
### Root Directory
All essential files are in the root directory:
- `docker-compose.yml` - Docker Compose configuration
- `.env.example` - Example environment configuration
- `GETTING-STARTED.md` - Quick start guide for new users
### Docker Files
Docker-related files are located in the `docker` directory:
- `Dockerfile` - Production Docker image
- `Dockerfile.dev` - Development Docker image with hot-reloading
### Scripts
Helper scripts are located in the `scripts` directory:
- `install.sh` - Interactive installation and setup script
- Various startup scripts for different environments
- [Trademark Notice](#trademark-notice)
- [Support](#-support)
## 🛠️ Configuration
### Environment Configuration
### Environment Variables
Pulse uses a single `.env` file for configuration with environment variables that control the behavior:
1. **Copy Example File:** This application requires environment variables for configuration. Copy the example environment file from `server/.env.example` to `server/.env`.
```bash
# Required: Proxmox Configuration
PROXMOX_NODE_1_NAME=pve
PROXMOX_NODE_1_HOST=https://proxmox.local:8006
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-token-secret
```bash
cp server/.env.example server/.env
```
# Optional: Application Configuration
NODE_ENV=production # or 'development' for development mode
USE_MOCK_DATA=false # set to 'true' to use mock data instead of real Proxmox data
```
2. **Edit `.env`:** Open `server/.env` in a text editor and update the values for your Proxmox environment, including the Host, Token ID, and Token Secret obtained below.
### Quick Setup
The easiest way to configure Pulse is to copy the example environment file and edit it:
```bash
# Copy the example environment file
cp .env.example .env
# Edit the .env file with your favorite editor
nano .env # or use vim, VS Code, etc.
```
Add your Proxmox node details to the `.env` file and save it. See the [Environment Configuration](#environment-configuration) section for details on the required settings.
### Manual Configuration
If you prefer to configure Pulse manually:
1. Copy the example environment file:
```bash
cp .env.example .env
```
2. Edit the `.env` file with at least these settings:
```bash
# Required: Proxmox Configuration
PROXMOX_NODE_1_NAME=pve
PROXMOX_NODE_1_HOST=https://proxmox.local:8006
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-token-secret
```
The following variables are available:
- `PROXMOX_HOST`: URL of your Proxmox server (e.g., `https://your-proxmox-ip:8006`).
- `PROXMOX_TOKEN_ID`: Your API Token ID (e.g., `user@pam!tokenid`).
- `PROXMOX_TOKEN_SECRET`: Your API Token Secret.
- `PROXMOX_ALLOW_SELF_SIGNED_CERTS`: (Optional) Set to `true` if your Proxmox server uses self-signed SSL certificates. Defaults to `false`.
- `PORT`: (Optional) Port for the Pulse server to listen on. Defaults to `7655`.
- `PROXMOX_USERNAME`, `PROXMOX_PASSWORD`, `PROXMOX_REALM`: (Optional) Fallback credentials if API token is not provided.
### Creating a Proxmox API Token
#### Option 1: Quick Command (Convenient but less secure)
An API token is recommended for connecting Pulse to Proxmox.
You can run this command either by SSH'ing into your Proxmox server or by using the Shell console in the Proxmox web UI (Datacenter → Shell):
1. **Log in to the Proxmox web interface**
```bash
# This creates a token named 'pulse' to match the example in the .env file
pveum user token add root@pam pulse --privsep=0 && \
pveum acl modify / -user root@pam -role PVEAuditor && \
pveum user token list root@pam
```
2. **Create a dedicated user** (optional but recommended for security)
* Go to `Datacenter` → `Permissions` → `Users`.
* Click `Add`.
* Enter a `User name` (e.g., "pulse-monitor"), set Realm to `Proxmox VE authentication server`, set a password, and ensure `Enabled` is checked. Click `Add`.
⚠️ **Why this is less secure:**
- Uses the root account (best practice is to use a dedicated user)
- **Disables** privilege separation with `--privsep=0` (privilege separation restricts token permissions)
- Grants access to all resources (/)
- Outputs the token secret to the terminal (could be logged)
3. **Create an API token**
* Go to `Datacenter` → `Permissions` → `API Tokens`.
* Click `Add`.
* Select the `User` you created (e.g., "pulse-monitor@pam") or `root@pam`.
* Enter a `Token ID` (e.g., "pulse").
* Leave `Privilege Separation` checked (more secure).
* Click `Add`.
* **Important:** Copy the displayed `Secret` value immediately and store it securely. It will only be shown once.
#### Option 2: Step-by-Step Guide (More secure)
4. **Assign permissions**
* Go to `Datacenter` → `Permissions` → `Add` → `User Permission`.
* Path: `/`
* User: Select the user the token belongs to (e.g., "pulse-monitor@pam").
* Role: `PVEAuditor` (provides read-only access).
* Ensure `Propagate` is checked.
* Click `Add`.
1. **Log in to the Proxmox web interface**
2. **Create a dedicated user** (optional but recommended)
- Go to Datacenter → Permissions → Users
- Click "Add"
- Enter a username (e.g., "pulse-monitor")
- Set a password and enable the user
3. **Create an API token**
- Go to Datacenter → Permissions → API Tokens
- Click "Add"
- Select your user (e.g., "pulse-monitor@pam" or "root@pam")
- Enter a token ID (e.g., "pulse")
- Leave "Privilege Separation" checked for better security (this restricts the token to only use permissions explicitly granted to it)
- Click "Add"
- **Important:** Save the displayed token value securely - it will only be shown once!
4. **Assign permissions**
- Go to Datacenter → Permissions → Add
- Path: /
- User: Your user (e.g., "pulse-monitor@pam")
- Role: PVEAuditor
- Click "Add"
5. **Update your .env file**
```
# If using root user (matching the quick command example)
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-saved-token-value
# OR if using a dedicated user (recommended for better security)
PROXMOX_NODE_1_TOKEN_ID=pulse-monitor@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-saved-token-value
```
5. **Update your `server/.env` file** with the `Token ID` (which looks like `user@realm!tokenid`, e.g., `pulse-monitor@pam!pulse`) and the `Secret` you saved.
### Required Permissions
Your Proxmox API token needs these permissions:
- PVEAuditor role or custom role with:
- Datastore.Audit
- VM.Audit
- Sys.Audit
- Pool.Audit
The `PVEAuditor` role is recommended as it provides the necessary read-only permissions for Pulse to monitor your Proxmox environment:
- `Datastore.Audit`
- `Permissions.Read` (implicitly included)
- `Pool.Audit`
- `Sys.Audit`
- `VM.Audit`
These permissions allow Pulse to read metrics and status information without making any changes to your Proxmox environment.
## 💾 Installation
## 🛠️ Common Commands
Navigate to the project root directory and install the necessary Node.js dependencies.
```bash
# Development mode with mock data (quickest way to start)
# On Linux/macOS
./scripts/start-dev.sh
# On Windows
scripts\start-dev.bat
# Install root dependencies
npm install
```
# Development mode with mock data (using npm)
You also need to install dependencies for the server component:
```bash
# Install server dependencies
cd server
npm install
cd ..
```
## ▶️ Running the Application (Node.js)
These instructions are for running the application directly using Node.js.
### Development Mode
To run the application in development mode (useful for testing changes):
```bash
npm run dev
# Production mode with real Proxmox data (local)
npm run prod
# Development mode with mock data (Docker)
npm run dev:docker
# Production mode with real Proxmox data (Docker)
npm run prod:docker
# Stop Docker containers
npm run stop
# Clean up (remove containers, images, volumes)
npm run cleanup
```
This command starts the server, typically using `nodemon` or similar for automatic restarts on file changes. Check the terminal output for the URL (e.g., `http://localhost:7655`).
## 🐳 Docker Configuration
### Production Mode
Pulse uses a single Docker Compose file with environment variables to control the behavior:
To run the application normally:
### Docker Files
- `docker-compose.yml` - Unified compose file for all environments
- `docker/Dockerfile` - Production image with optimized build
- `docker/Dockerfile.dev` - Development image with hot reloading
### Environment Modes
The environment variables in your `.env` file control how Docker behaves:
#### Production Mode (Default)
```
NODE_ENV=production
DOCKERFILE=docker/Dockerfile
USE_MOCK_DATA=false
MOCK_DATA_ENABLED=false
```
#### Development Mode
To run in development mode, set these variables in your `.env` file:
```
NODE_ENV=development
DOCKERFILE=docker/Dockerfile.dev
USE_MOCK_DATA=true
MOCK_DATA_ENABLED=true
DEV_SRC_MOUNT=./src:/app/src
DEV_FRONTEND_SRC_MOUNT=./frontend/src:/app/frontend/src
DEV_FRONTEND_PUBLIC_MOUNT=./frontend/public:/app/frontend/public
DEV_FRONTEND_INDEX_MOUNT=./frontend/index.html:/app/frontend/index.html
DEV_FRONTEND_CONFIG_MOUNT=./frontend/vite.config.js:/app/frontend/vite.config.js
DEV_SCRIPTS_MOUNT=./scripts:/app/scripts
DEV_ENV_MOUNT=./environments:/app/environments
```
### Running Different Environments
You can use the launcher script to choose an environment:
```bash
./start.sh # or start.bat on Windows
npm run start
```
This command starts the server using `node`. Access the application via the configured host and port.
## 🐳 Running with Docker Compose
Using Docker Compose is the recommended way to run the application in a containerized environment.
**Prerequisites:**
- Docker ([Install Docker](https://docs.docker.com/engine/install/))
- Docker Compose ([Install Docker Compose](https://docs.docker.com/compose/install/))
**Steps:**
1. **Configure Environment:** Ensure you have created and configured your `server/.env` file as described in the [Environment Variables](#environment-variables) section above.
2. **Run:** Navigate to the project root directory in your terminal and run:
```bash
docker compose up -d
```
- This command will download the pre-built `rcourtman/pulse:latest` image from Docker Hub (if not already present) and start the container.
- `-d`: Runs the container in detached mode (in the background).
3. **Access:** The application should now be running. Access it via `http://<your-host-ip>:7655` (or the host port you mapped in `docker-compose.yml`).
**Stopping the Application:**
To stop the container(s) defined in the `docker-compose.yml` file, run:
Or use npm scripts:
```bash
# Development with mock data (local)
npm run dev
# Development with mock data (Docker)
npm run dev:docker
# Production (local)
npm run prod
# Production (Docker)
npm run prod:docker
docker compose down
```
## ✨ Features
- Real-time monitoring of Proxmox nodes, VMs, and containers
- Dashboard with summary cards for nodes, guests, and resources
- Responsive design that works on desktop and mobile
- WebSocket connection for live updates
- Automatic Proxmox cluster detection and support
- Cluster mode for properly handling VMs/containers with the same ID across cluster nodes
## ❓ Frequently Asked Questions
### How does Pulse compare to Grafana + InfluxDB monitoring?
Pulse and Grafana serve different monitoring needs. Pulse focuses on real-time monitoring with WebSocket-based instant updates, ideal for active system monitoring and dashboards. It's lightweight (single Docker container) and simple to set up. Grafana+InfluxDB is better suited for historical data analysis, complex visualizations, and monitoring multiple systems. If you need detailed historical metrics or custom dashboards, use Grafana. If you want instant resource updates and a simple setup, use Pulse.
### How is it different from the built-in Proxmox summary?
While Proxmox's built-in summary is great for management, Pulse offers:
- Real-time WebSocket updates
- All nodes visible on one screen
- Monitoring without logging into Proxmox
- Ability to share monitoring access without admin privileges
- Lightweight resource usage
- Perfect for dedicated monitoring displays
### Will Pulse support Proxmox Backup Server (PBS)?
PBS integration is planned. The PBS API provides the metrics needed for backup job status, datastore usage tracking, and verification monitoring. A roadmap of planned features will be published soon.
### What about hardware monitoring (temperatures, additional disks)?
Hardware metric expansion is in development, including temperature sensors and additional disk metrics. The focus is on keeping the interface clean and responsive while adding these features.
### How resource-intensive is Pulse?
Pulse is designed to be lightweight, requiring minimal resources (256MB RAM, 1 CPU core). It runs as a single Docker container and doesn't store historical data, keeping the resource footprint small.
### What's the long-term plan for this project?
Pulse is actively maintained and used daily. I'm committed to keeping it relevant and useful, with a focus on stability and thoughtful feature additions. A public roadmap will be published soon to share planned features and improvements.
### Does Pulse support Proxmox clusters?
Yes, Pulse automatically detects if your Proxmox nodes are part of a cluster. When a cluster is detected, Pulse enables cluster mode automatically, which properly handles VMs and containers that have the same ID across different nodes in the cluster. This prevents duplicate entries and ensures consistent monitoring across your entire cluster.
If you're not running a Proxmox cluster (standalone nodes), you don't need to worry about these settings. The application will automatically detect that your nodes are not in a cluster and will operate in non-cluster mode, showing all VMs and containers from each node individually.
You don't need to configure anything - it just works! However, if you want to customize the behavior, you can use these environment variables:
- `PROXMOX_AUTO_DETECT_CLUSTER=false` - Disable automatic cluster detection
- `PROXMOX_CLUSTER_MODE=false` - Disable cluster mode even if a cluster is detected
- `PROXMOX_CLUSTER_NAME=my-cluster` - Set a custom name for your cluster (defaults to the detected name)
### Does Pulse collect any telemetry or user data?
No. Pulse only communicates directly with your Proxmox servers using the API token you provide. No data is sent outside your network, and the entire codebase is open source for verification.
## 🔍 Troubleshooting
### WebSocket Connection Issues
If you see a "Connection error: websocket error" message, it's typically because the WebSocket connection can't be established. This is often due to Docker networking or reverse proxy configuration.
#### Quick Fixes:
1. **Make sure you're using the latest version of Pulse:**
```bash
docker pull rcourtman/pulse:latest
docker restart pulse
```
2. **Remove VITE_API_URL from your .env file** if you've set it.
3. **Access Pulse directly by IP address** instead of using localhost or a domain name.
4. **As a last resort, if other solutions don't work, you can use host network mode:**
```bash
docker run -d --network host --env-file .env --name pulse rcourtman/pulse:latest
```
### Logging and Debugging
Pulse includes powerful logging tools to help you troubleshoot issues:
1. **Run with real-time log monitoring:**
```bash
npm run dev:logs # Development mode with logs
npm run prod:logs # Production mode with logs
```
2. **Monitor specific log types:**
```bash
npm run logs:errors # Show only errors
npm run logs:cluster # Show cluster-related logs
npm run logs:proxmox # Show Proxmox client logs
```
3. **Filter logs by component or search term:**
```bash
node scripts/monitor-logs.js --component=NodeManager
node scripts/monitor-logs.js --search="connection"
```
For more detailed information about logging, see the [Logging and Debugging Guide](docs/logging.md).
## 📋 Advanced Configuration
For multiple Proxmox nodes or advanced settings, add these to your `.env`:
```bash
# Node 1 (default)
PROXMOX_NODE_1_NAME=pve
PROXMOX_NODE_1_HOST=https://proxmox.local:8006
PROXMOX_NODE_1_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_1_TOKEN_SECRET=your-token-secret
# Node 2 (additional node)
PROXMOX_NODE_2_NAME=pve2
PROXMOX_NODE_2_HOST=https://proxmox2.local:8006
PROXMOX_NODE_2_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_2_TOKEN_SECRET=your-token-secret
# Node 3 (additional node)
PROXMOX_NODE_3_NAME=pve3
PROXMOX_NODE_3_HOST=https://proxmox3.local:8006
PROXMOX_NODE_3_TOKEN_ID=root@pam!pulse
PROXMOX_NODE_3_TOKEN_SECRET=your-token-secret
# SSL Configuration
IGNORE_SSL_ERRORS=true # Set to true to disable SSL certificate verification
NODE_TLS_REJECT_UNAUTHORIZED=0 # Set to 0 to disable SSL certificate verification
# Cluster Configuration
PROXMOX_AUTO_DETECT_CLUSTER=false # Set to false to disable automatic cluster detection
PROXMOX_CLUSTER_MODE=false # Set to false to disable cluster mode even if a cluster is detected
PROXMOX_CLUSTER_NAME=my-cluster # Custom name for your cluster (defaults to detected name)
# App Configuration
PORT=7654
LOG_LEVEL=info # Options: error, warn, info, debug, silly
```
### Advanced Metrics Configuration
For information about the metrics service and how to configure it, see the [Metrics Service Documentation](docs/metrics-service.md).
You can configure the metrics service behavior with these environment variables:
```bash
# Maximum history length (number of data points to keep)
METRICS_HISTORY_LENGTH=100
# Polling interval in milliseconds
METRICS_POLLING_INTERVAL=2000
# Maximum realistic rate in MB/s (default: 125 MB/s)
METRICS_MAX_REALISTIC_RATE=125
```
### Performance Tuning
If you notice Pulse is consuming too many resources or overwhelming your Proxmox server, you can adjust several settings in your `.env` file:
```bash
# Polling Intervals - how often to fetch data from Proxmox API
NODE_POLLING_INTERVAL_MS=15000 # Recommended default (original was 3000ms)
EVENT_POLLING_INTERVAL_MS=5000 # Recommended default (original was 1000ms)
# API Rate Limiting - controls API request frequency
API_RATE_LIMIT_MS=2000 # Minimum time between API requests
API_TIMEOUT_MS=90000 # Timeout for API requests
API_RETRY_DELAY_MS=10000 # Delay before retrying failed requests
# Memory Usage - controls how much data is kept in memory
METRICS_HISTORY_MINUTES=30 # Recommended default (original was 60 minutes)
# Reduce API calls by disabling cluster features if not needed
PROXMOX_CLUSTER_MODE=false # Disable cluster mode
PROXMOX_AUTO_DETECT_CLUSTER=false # Disable cluster detection
```
For more detailed information on performance tuning, see the [Getting Started Guide](GETTING-STARTED.md#performance-tuning).
## 🧑‍💻 Development
If you're developing Pulse, you can use the development server:
```bash
# Clone the repository
git clone https://github.com/rcourtman/pulse.git
cd pulse
# Install dependencies
npm install
cd frontend && npm install && cd ..
# Start the development server with mock data (local)
npm run dev
# Start the development server with mock data (Docker)
npm run dev:docker
```
### Development Scripts
Pulse provides several npm scripts for different development scenarios:
```bash
# Standard development with mock data (cluster mode enabled by default)
npm run dev
# Development with mock data but cluster mode disabled
# This shows all VMs/containers from all nodes, even duplicates
npm run dev:no-cluster
# Development with Docker (cluster mode enabled by default)
npm run dev:docker
# Development with Docker but cluster mode disabled
npm run dev:docker:no-cluster
```
The main difference between `npm run dev` and `npm run dev:no-cluster` is how VMs and containers with the same ID across different nodes are handled:
- With `npm run dev` (default): VMs/containers with the same ID are deduplicated as if they're in a cluster
- With `npm run dev:no-cluster`: All VMs/containers from all nodes are shown, even if they have the same ID
### Environment Configuration
Pulse uses a single `.env` file for configuration. When running in development mode, the application automatically sets development-specific environment variables:
```bash
NODE_ENV=development
USE_MOCK_DATA=true
MOCK_DATA_ENABLED=true
```
You can override these settings in your `.env` file if needed.
### Testing Cluster Mode with Mock Data
When using mock data, Pulse simulates nodes that are part of a cluster by default. This allows you to test the cluster detection and handling functionality without needing a real Proxmox cluster.
You can control this behavior with these environment variables:
```bash
# Set to 'false' to disable mock cluster mode
MOCK_CLUSTER_ENABLED=true
# Custom name for the mock cluster
MOCK_CLUSTER_NAME=mock-cluster
```
This is useful for testing how Pulse handles VMs and containers with the same ID across different nodes in a cluster.
#### Security Note
⚠️ **IMPORTANT**: The `.env` file contains sensitive information such as API tokens and should NEVER be committed to the repository. An example file without sensitive data is provided (`.env.example`) as a template.
To set up your environment:
1. Copy the example file to create your actual environment file:
```bash
cp .env.example .env
```
2. Edit the file to add your actual Proxmox node details and API tokens.
The `.gitignore` file is configured to exclude this sensitive file from being committed.
### Development Architecture
Pulse uses a split architecture for development:
- **Backend server** (port 7655): Node.js Express server that communicates with Proxmox
- **Frontend server** (port 7654): Vite development server for the React frontend
This separation provides several benefits:
- **Hot Module Replacement (HMR)**: Changes to frontend code are instantly reflected without a full page reload
- **Independent development**: Backend and frontend can be developed and tested separately
- **API isolation**: Clear separation between data services and UI components
When you run `npm run dev`, both servers start automatically:
1. The backend server runs on port 7655 and handles all Proxmox API communication
2. The frontend development server runs on port 7654 with hot reloading enabled
3. API requests from the frontend are proxied to the backend
In production, these are combined into a single service running on port 7654.
### Developer Documentation
For more detailed information about the codebase structure, key components, and design decisions, please refer to the [Developer Documentation](docs/DEVELOPER.md).
The development server will be accessible at:
- http://localhost:7654 - from the local machine
- http://your-ip-address:7654 - from other devices on your network
- Lightweight monitoring for Proxmox VE nodes.
- Displays real-time status for VMs and Containers.
- Simple web interface.
## 💻 System Requirements
- **Docker**: Version 20.10.0 or higher
- **Memory**: Minimum 256MB RAM (512MB recommended)
- **CPU**: Any modern CPU (1+ cores)
- **Disk Space**: Approximately 100MB for the Docker image
- **Network**: Connectivity to your Proxmox server(s)
- **Browser**: Any modern browser (Chrome, Firefox, Safari, Edge)
- **Node.js** (for local development/running): Version 16.x or higher recommended.
- **Docker & Docker Compose** (for containerized deployment): Recent versions recommended.
- **Network**: Connectivity between the Pulse server and your Proxmox server(s).
## 🔄 Version Information
Current version: 1.2.1
To check for updates:
```bash
# Check for newer image versions
docker pull rcourtman/pulse:latest
# View current running version
docker exec pulse cat /app/package.json | grep version
```
## 👥 Contributing
Contributions are welcome! Here's how you can contribute:
Contributions are welcome! Please follow standard fork-and-pull-request workflow. Refer to the main repository's contributing guidelines if available.
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Commit your changes**: `git commit -m 'Add some amazing feature'`
4. **Push to the branch**: `git push origin feature/amazing-feature`
5. **Open a Pull Request**
Please make sure to update tests as appropriate and follow the code style of the project.
For more detailed information about contributing, please see our [Contributing Guidelines](CONTRIBUTING.md).
### Reporting Issues
When reporting issues, please use the appropriate issue template:
- [Bug Report](.github/ISSUE_TEMPLATE/bug_report.md) - for reporting bugs or unexpected behavior
- [Feature Request](.github/ISSUE_TEMPLATE/feature_request.md) - for suggesting new features or improvements
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Trademark Notice
Proxmox® and Proxmox VE® are registered trademarks of Proxmox Server Solutions GmbH. Pulse for Proxmox VE is an independent project and is not affiliated with, endorsed by, or sponsored by Proxmox Server Solutions GmbH.
## ❤️ Support
If you find Pulse helpful, please consider supporting its development through Ko-fi. Your support helps keep this project maintained and free for everyone!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman)
1. **Fork the repository**
2. **Create a feature branch**: `
-30
View File
@@ -1,30 +0,0 @@
# Security Policy
## Reporting a Vulnerability
The Pulse for Proxmox VE team takes security issues seriously. We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
To report a security vulnerability, please follow these steps:
1. **DO NOT** disclose the vulnerability publicly (e.g., in GitHub issues)
2. Create a private security advisory in the GitHub repository by going to Security → Advisories → New advisory
3. Allow time for the issue to be addressed before disclosing it publicly
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.2.x | :white_check_mark: |
| < 1.2.0 | :x: |
## Security Updates
Security updates will be released as part of our regular release cycle or as emergency patches depending on severity.
## Best Practices for Secure Usage
1. **API Tokens**: Always use the principle of least privilege when creating Proxmox API tokens for Pulse
2. **Network Security**: Consider running Pulse behind a reverse proxy with HTTPS if exposing it to the internet
3. **Regular Updates**: Keep Pulse updated to the latest version to benefit from security patches
Thank you for helping keep Pulse and its users safe!
-225
View File
@@ -1,225 +0,0 @@
// Debug script to understand the filter UI structure
const fs = require('fs');
const puppeteer = require('puppeteer');
(async () => {
console.log('Starting debug script...');
const browser = await puppeteer.launch({
headless: true,
defaultViewport: { width: 1440, height: 900 }
});
try {
const page = await browser.newPage();
// Navigate to the resources page
await page.goto('http://localhost:7654/resources');
console.log('Navigated to resources page');
// Wait for the page to load
await page.waitForSelector('#root');
console.log('Page loaded');
// Take a screenshot of the initial state
await page.screenshot({ path: 'initial-state.png' });
console.log('Initial screenshot taken');
// Find and click the filter button
const filterButtonSelector = 'button[data-filter-button="true"]';
await page.waitForSelector(filterButtonSelector, { timeout: 5000 })
.catch(() => console.log('Filter button selector not found'));
// Try different selectors for the filter button
const filterButtonFound = await page.evaluate(() => {
const selectors = [
'button[data-filter-button="true"]',
'button:has(svg[data-testid="FilterAltIcon"])',
'button.MuiButton-root:has(.MuiButton-startIcon)',
'button:has(svg)',
'button:contains("Filter")'
];
for (const selector of selectors) {
try {
const elements = document.querySelectorAll(selector);
console.log(`Selector ${selector} found ${elements.length} elements`);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
console.log(`Element ${i}: ${el.outerHTML.substring(0, 100)}...`);
// If this looks like a filter button, click it
if (el.textContent.includes('Filter') ||
el.getAttribute('data-filter-button') === 'true' ||
el.getAttribute('aria-label')?.includes('Filter')) {
console.log('Clicking filter button');
el.click();
return true;
}
}
} catch (e) {
console.log(`Error with selector ${selector}: ${e.message}`);
}
}
// Try a more direct approach - find buttons with Filter text
const buttons = Array.from(document.querySelectorAll('button'));
for (const button of buttons) {
if (button.textContent.includes('Filter')) {
console.log('Found button with Filter text, clicking it');
button.click();
return true;
}
}
return false;
});
console.log(`Filter button found and clicked: ${filterButtonFound}`);
// Wait for filter panel to appear
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 2000)));
// Take a screenshot with filter panel open
await page.screenshot({ path: 'filter-panel-open.png' });
console.log('Filter panel screenshot taken');
// Debug the filter panel structure
const filterPanelInfo = await page.evaluate(() => {
const info = {
inputs: [],
sliders: [],
buttons: [],
filterPanelVisible: false
};
// Check if there's any visible dialog or panel that might be the filter panel
const possiblePanels = document.querySelectorAll('.MuiDialog-root, .MuiDrawer-root, .MuiPopover-root, [role="dialog"], [role="menu"]');
info.possiblePanelsCount = possiblePanels.length;
for (const panel of possiblePanels) {
if (window.getComputedStyle(panel).display !== 'none') {
info.filterPanelVisible = true;
info.panelHTML = panel.outerHTML.substring(0, 500) + '...';
// Get inputs in the panel
const inputs = panel.querySelectorAll('input');
info.inputs = Array.from(inputs).map(input => ({
type: input.type,
placeholder: input.placeholder,
id: input.id,
name: input.name,
value: input.value,
outerHTML: input.outerHTML
}));
// Get sliders in the panel
const sliders = panel.querySelectorAll('input[type="range"]');
info.sliders = Array.from(sliders).map(slider => ({
min: slider.min,
max: slider.max,
value: slider.value,
id: slider.id,
outerHTML: slider.outerHTML
}));
// Get buttons in the panel
const buttons = panel.querySelectorAll('button');
info.buttons = Array.from(buttons).map(button => ({
text: button.textContent,
type: button.type,
outerHTML: button.outerHTML
}));
break;
}
}
return info;
});
console.log('Filter panel info:', JSON.stringify(filterPanelInfo, null, 2));
fs.writeFileSync('filter-panel-info.json', JSON.stringify(filterPanelInfo, null, 2));
// Try to apply filters if the panel is visible
if (filterPanelInfo.filterPanelVisible) {
const filtersApplied = await page.evaluate(() => {
const results = {
searchApplied: false,
slidersAdjusted: false,
filtersApplied: false
};
// Find and fill the search input
const searchInputs = document.querySelectorAll('input[type="text"]');
for (const input of searchInputs) {
if (input.placeholder && input.placeholder.toLowerCase().includes('search')) {
console.log('Found search input, setting value to ubuntu');
input.value = 'ubuntu';
input.dispatchEvent(new Event('input', { bubbles: true }));
results.searchApplied = true;
break;
}
}
// Find and adjust sliders
const sliders = document.querySelectorAll('input[type="range"]');
if (sliders.length > 0) {
console.log(`Found ${sliders.length} sliders`);
sliders[0].value = 50;
sliders[0].dispatchEvent(new Event('input', { bubbles: true }));
sliders[0].dispatchEvent(new Event('change', { bubbles: true }));
if (sliders.length > 1) {
sliders[1].value = 30;
sliders[1].dispatchEvent(new Event('input', { bubbles: true }));
sliders[1].dispatchEvent(new Event('change', { bubbles: true }));
}
results.slidersAdjusted = true;
}
// Find and click apply button
const buttons = document.querySelectorAll('button');
for (const button of buttons) {
if (button.textContent.includes('Apply')) {
console.log('Found Apply button, clicking it');
button.click();
results.filtersApplied = true;
break;
}
}
return results;
});
console.log('Filters applied:', JSON.stringify(filtersApplied, null, 2));
// Wait for filters to be applied
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 2000)));
// Take a screenshot with filters applied
await page.screenshot({ path: 'filters-applied.png' });
console.log('Filters applied screenshot taken');
}
// Navigate to memory sort page
await page.goto('http://localhost:7654/resources?sort=memory&order=desc');
console.log('Navigated to memory sort page');
// Wait for the page to load
await page.waitForSelector('#root');
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 2000)));
// Take a screenshot of the memory sort page
await page.screenshot({ path: 'memory-sort.png' });
console.log('Memory sort screenshot taken');
console.log('Debug script completed successfully');
} catch (error) {
console.error('Error in debug script:', error);
} finally {
await browser.close();
}
})();
+24 -64
View File
@@ -1,66 +1,26 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: ${DOCKERFILE:-docker/Dockerfile}
args:
- NODE_ENV=${NODE_ENV:-production}
container_name: ${CONTAINER_NAME:-pulse}
pulse-server:
# Build context commented out - using pre-built image from Docker Hub
# build:
# context: .
# dockerfile: Dockerfile
image: rcourtman/pulse:latest # Use the pre-built image from Docker Hub
container_name: pulse_server_simplified
restart: unless-stopped
ports:
- "7654:7654" # Backend API
- "3000:3000" # Frontend dev server
- "7656:7656" # Mock data server (only used in dev mode)
volumes:
- ./logs:/app/logs
# Development mode mounts - only used when DEV_*_MOUNT variables are set in .env
- ${DEV_SRC_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_FRONTEND_SRC_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_FRONTEND_PUBLIC_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_FRONTEND_INDEX_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_FRONTEND_CONFIG_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_SCRIPTS_MOUNT:-./null-volume:/app/null-volume}
- ${DEV_ENV_MOUNT:-./null-volume:/app/null-volume}
environment:
- NODE_ENV=${NODE_ENV:-production}
- LOG_LEVEL=${LOG_LEVEL:-info}
- DOCKER_CONTAINER=true
- USE_MOCK_DATA=${USE_MOCK_DATA:-false}
- MOCK_DATA_ENABLED=${MOCK_DATA_ENABLED:-false}
- MOCK_SERVER_PORT=${MOCK_SERVER_PORT:-7656}
- DRY_RUN=${DRY_RUN:-false}
# Performance tuning settings to reduce server load
- NODE_POLLING_INTERVAL_MS=${NODE_POLLING_INTERVAL_MS:-15000}
- EVENT_POLLING_INTERVAL_MS=${EVENT_POLLING_INTERVAL_MS:-5000}
- METRICS_HISTORY_MINUTES=${METRICS_HISTORY_MINUTES:-30}
- API_RATE_LIMIT_MS=${API_RATE_LIMIT_MS:-2000}
- API_TIMEOUT_MS=${API_TIMEOUT_MS:-90000}
- API_RETRY_DELAY_MS=${API_RETRY_DELAY_MS:-10000}
# Proxmox configuration - Node 1
- PROXMOX_NODE_1_NAME=${PROXMOX_NODE_1_NAME:-}
- PROXMOX_NODE_1_HOST=${PROXMOX_NODE_1_HOST:-}
- PROXMOX_NODE_1_TOKEN_ID=${PROXMOX_NODE_1_TOKEN_ID:-}
- PROXMOX_NODE_1_TOKEN_SECRET=${PROXMOX_NODE_1_TOKEN_SECRET:-}
# Proxmox configuration - Node 2 (optional)
- PROXMOX_NODE_2_NAME=${PROXMOX_NODE_2_NAME:-}
- PROXMOX_NODE_2_HOST=${PROXMOX_NODE_2_HOST:-}
- PROXMOX_NODE_2_TOKEN_ID=${PROXMOX_NODE_2_TOKEN_ID:-}
- PROXMOX_NODE_2_TOKEN_SECRET=${PROXMOX_NODE_2_TOKEN_SECRET:-}
# Proxmox configuration - Node 3 (optional)
- PROXMOX_NODE_3_NAME=${PROXMOX_NODE_3_NAME:-}
- PROXMOX_NODE_3_HOST=${PROXMOX_NODE_3_HOST:-}
- PROXMOX_NODE_3_TOKEN_ID=${PROXMOX_NODE_3_TOKEN_ID:-}
- PROXMOX_NODE_3_TOKEN_SECRET=${PROXMOX_NODE_3_TOKEN_SECRET:-}
# SSL verification settings
- IGNORE_SSL_ERRORS=${IGNORE_SSL_ERRORS:-false}
- NODE_TLS_REJECT_UNAUTHORIZED=${NODE_TLS_REJECT_UNAUTHORIZED:-}
- PROXMOX_REJECT_UNAUTHORIZED=${PROXMOX_REJECT_UNAUTHORIZED:-}
- HTTPS_REJECT_UNAUTHORIZED=${HTTPS_REJECT_UNAUTHORIZED:-}
- PROXMOX_INSECURE=${PROXMOX_INSECURE:-}
- PROXMOX_VERIFY_SSL=${PROXMOX_VERIFY_SSL:-}
restart: ${RESTART_POLICY:-unless-stopped}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7654/api/status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# Map container port 7655 to host port 7655
# You can change the host port (left side) if 7655 is already in use on your host
- "7655:7655"
env_file:
# Load environment variables from the .env file located in the server directory
- ./server/.env
# Optional: Define networks if needed, otherwise uses default bridge network
# networks:
# - pulse_network
# Optional: Define a network
# networks:
# pulse_network:
# driver: bridge
-128
View File
@@ -1,128 +0,0 @@
# Frontend build stage
FROM node:20-slim AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files for dependency caching
COPY frontend/package*.json ./
# Install frontend dependencies
RUN npm ci
# Copy frontend source files
COPY frontend/src ./src
COPY frontend/index.html ./
COPY frontend/vite.config.js ./
COPY frontend/public ./public
# Build the frontend
RUN npm run build
# Backend build stage
FROM node:20-slim AS backend-builder
WORKDIR /app
# Install dependencies for canvas
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python-is-python3 \
make \
g++ \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy package files for dependency caching
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy backend source files
COPY src ./src
COPY tsconfig*.json ./
# Build the backend
RUN npm run build
# Production stage
FROM node:20-slim
# Add metadata
LABEL version="1.6.4"
LABEL description="Pulse - A lightweight monitoring application for Proxmox VE"
LABEL maintainer="Richard Courtman <richard@courtman.me>"
# Install runtime dependencies for canvas and create a non-root user
RUN apt-get update && apt-get install -y --no-install-recommends \
dumb-init \
curl \
python3 \
python-is-python3 \
make \
g++ \
build-essential \
pkg-config \
libpixman-1-dev \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r pulse && useradd -r -g pulse pulse
WORKDIR /app
# Copy only production dependencies
COPY package*.json ./
RUN npm ci --omit=dev
# Copy built files from builder stages
COPY --from=backend-builder /app/dist ./dist
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Copy scripts directory for startup scripts
COPY scripts ./scripts
# Create logs directory with proper permissions
RUN mkdir -p /app/logs && chown -R pulse:pulse /app/logs
# Set environment variables
ENV PORT=7654
ENV NODE_ENV=production
ENV DRY_RUN=false
# Create a startup script that properly handles dry run mode
RUN echo '#!/bin/sh\n\
if [ "$DRY_RUN" = "true" ]; then\n\
echo "================================================="\n\
echo "Dry run mode enabled - not starting production server"\n\
echo "================================================="\n\
echo "Would have run: node dist/server.js"\n\
echo "Environment: NODE_ENV=$NODE_ENV, PORT=$PORT"\n\
echo "================================================="\n\
exit 0\n\
else\n\
node dist/server.js\n\
fi' > /app/docker-entrypoint.sh && \
chmod +x /app/docker-entrypoint.sh
# Switch to non-root user
USER pulse
# Expose the backend port
EXPOSE 7654
# Use dumb-init to handle signals properly
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Use the startup script instead of inline command
CMD ["/app/docker-entrypoint.sh"]
-67
View File
@@ -1,67 +0,0 @@
FROM node:20-slim
# Install dependencies for canvas and development tools
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python-is-python3 \
make \
g++ \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
procps \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files for dependency installation
COPY package*.json ./
COPY frontend/package*.json ./frontend/
# Install backend dependencies and global development tools
RUN npm install
RUN npm install -g ts-node-dev ts-node
# Install frontend dependencies
WORKDIR /app/frontend
RUN npm install
# Return to app directory
WORKDIR /app
# Copy configuration files (not source code, which will be mounted as volumes)
COPY tsconfig*.json ./
# Create logs directory
RUN mkdir -p /app/logs
# Set environment variable to indicate we're in a Docker container
ENV DOCKER_CONTAINER=true
ENV MOCK_SERVER_HOST=0.0.0.0
ENV DRY_RUN=false
# Expose ports for backend, frontend, and mock server
EXPOSE 7654 7655
# Create a startup script that checks for dry run
RUN echo '#!/bin/sh\n\
if [ "$DRY_RUN" = "true" ]; then\n\
echo "================================================="\n\
echo "Dry run mode enabled - not starting development server"\n\
echo "================================================="\n\
echo "Would have run: ./scripts/start-dev.sh"\n\
echo "Environment: NODE_ENV=$NODE_ENV, USE_MOCK_DATA=$USE_MOCK_DATA"\n\
echo "================================================="\n\
exit 0\n\
else\n\
./scripts/start-dev.sh\n\
fi' > /app/docker-entrypoint.sh && \
chmod +x /app/docker-entrypoint.sh
# Use the startup script as the entry point
CMD ["/app/docker-entrypoint.sh"]
-324
View File
@@ -1,324 +0,0 @@
# Developer Documentation
This document provides detailed information about the Proxmox Pulse codebase structure, key components, and design decisions to help new contributors understand the project.
## Project Architecture
Proxmox Pulse uses a modern web application architecture with a Node.js backend and a React frontend:
```
pulse/
├── src/ # Backend source code
│ ├── api/ # API routes and controllers
│ ├── config/ # Configuration settings
│ ├── mock/ # Mock data implementation
│ ├── routes/ # Express routes
│ ├── scripts/ # Backend utility scripts
│ ├── services/ # Business logic and external services
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions
│ ├── websocket/ # WebSocket implementation
│ └── server.ts # Main server entry point
├── frontend/ # Frontend React application
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── constants/ # Application constants
│ │ ├── context/ # React context providers
│ │ ├── hooks/ # Custom React hooks
│ │ ├── utils/ # Utility functions
│ │ ├── App.jsx # Main application component
│ │ └── main.jsx # Entry point
│ └── public/ # Static assets
├── scripts/ # Utility scripts
├── docs/ # Documentation
│ ├── images/ # Documentation images
│ └── *.md # Markdown documentation files
└── tools/ # Development tools
```
## Key Components
### Backend
#### Proxmox API Client (`src/api/proxmox-client.ts`)
This service handles all communication with the Proxmox API. It:
- Authenticates with Proxmox servers
- Retrieves node, VM, and container information
- Polls for updates at configured intervals
- Handles error conditions and retries
#### Mock Data Implementation (`src/mock/`)
The mock data implementation:
- Provides a standalone mock server (`src/mock/server.ts`)
- Generates realistic VM and container data with meaningful names
- Implements a mock client (`src/api/mock-client.ts`) that mimics the real Proxmox client
- Uses templates (`src/mock/templates.ts`) for VM and container names and OS types
#### WebSocket Server (`src/websocket/server.js`)
The WebSocket server:
- Broadcasts real-time updates to connected clients
- Manages client connections
- Handles authentication and authorization
- Implements message queuing for reliability
#### Metrics Collection (`src/services/metrics.js`)
This service:
- Collects and processes metrics from Proxmox nodes
- Calculates derived metrics (averages, trends)
- Maintains historical data for the configured retention period
- Optimizes data for transmission to clients
### Frontend
#### Dashboard Components (`frontend/src/components/Dashboard/`)
The dashboard components:
- Display summary information for all nodes
- Show resource usage charts
- Provide filtering and sorting options
- Handle responsive layouts for different screen sizes
#### Resource Monitoring (`frontend/src/components/Resources/`)
These components:
- Display detailed resource information for VMs and containers
- Implement real-time updates via WebSocket
- Provide filtering and search functionality
- Visualize resource usage with charts and graphs
#### Network Display (`frontend/src/components/NetworkDisplay/`)
The network display components:
- Show detailed information about VMs and containers
- Provide filtering by system type and visibility
- Implement search functionality
- Support exporting data with additional fields (Type, ID, Uptime)
#### WebSocket Client (`frontend/src/services/websocket.js`)
The WebSocket client:
- Establishes and maintains connection to the server
- Handles reconnection logic
- Processes incoming messages
- Dispatches updates to the application state
## Design Decisions
### Split Development Architecture
We use a split architecture during development:
- Backend server (port 7655): Handles Proxmox API communication
- Frontend server (port 7654): Provides hot reloading for React
- Mock data server (port 7655): Generates mock data for development
This separation allows for:
- Independent development of frontend and backend
- Better developer experience with hot reloading
- Clear separation of concerns
- Development without a real Proxmox server
In production, these are combined into a single service.
### Mock Data Development
The mock data implementation allows developers to:
- Develop and test without a real Proxmox server
- Work with realistic data that mimics production environments
- Test edge cases and error conditions
- Develop UI components with consistent data
To use mock data during development:
- Run `npm run dev:mock` to start the application with mock data
- Edit `src/mock/templates.ts` to customize the mock data
- Check logs at `/tmp/pulse-mock-server.log` for debugging
### Real-time Updates
We chose WebSockets for real-time updates because:
- They provide lower latency than polling
- They reduce server load compared to frequent HTTP requests
- They enable push notifications for important events
- They work well with the reactive nature of the frontend
### State Management
The frontend uses a combination of:
- React Context for global state
- React Query for server state
- Local component state for UI-specific state
This approach:
- Minimizes unnecessary re-renders
- Separates server and client state concerns
- Provides optimistic updates for better UX
### Error Handling
We implement comprehensive error handling:
- Backend errors are logged and monitored
- API errors are retried with exponential backoff
- User-facing errors are displayed with actionable information
- Critical errors trigger notifications
## Performance Considerations
### Data Optimization
To ensure good performance:
- We minimize data sent over the wire by filtering server-side
- We use compression for WebSocket messages
- We implement pagination for large datasets
- We optimize polling intervals based on data volatility
### Rendering Optimization
The frontend optimizes rendering by:
- Using React.memo for expensive components
- Implementing virtualized lists for large datasets
- Debouncing rapidly changing values
- Using web workers for CPU-intensive operations
## Testing
### Backend Testing
Backend tests focus on:
- Unit tests for business logic
- Integration tests for API endpoints
- Mock tests for external services
### Frontend Testing
Frontend tests include:
- Component tests with React Testing Library
- Hook tests for custom hooks
- End-to-end tests for critical user flows
## Common Development Tasks
### Development Environment Setup
To set up the development environment:
1. Clone the repository:
```bash
git clone https://github.com/rcourtman/pulse.git
cd pulse
```
2. Install dependencies:
```bash
npm install
cd frontend && npm install && cd ..
```
3. Configure your environment:
```bash
cp .env.example .env
# Edit .env with your Proxmox details or use mock data
```
4. Start the development server:
```bash
# With mock data (local)
npm run dev
# With mock data (Docker)
npm run dev:docker
# With real Proxmox servers (local)
npm run prod
# With real Proxmox servers (Docker)
npm run prod:docker
```
### Environment Configuration
Pulse uses a single `.env` file for configuration. When running in development mode, the application automatically sets development-specific environment variables:
```bash
NODE_ENV=development
USE_MOCK_DATA=true
MOCK_DATA_ENABLED=true
```
You can override these settings in your `.env` file if needed.
### Testing Cluster Mode with Mock Data
When using mock data, Pulse simulates nodes that are part of a cluster by default. This allows you to test the cluster detection and handling functionality without needing a real Proxmox cluster.
You can control this behavior with these environment variables:
```bash
# Set to 'false' to disable mock cluster mode
MOCK_CLUSTER_ENABLED=true
# Custom name for the mock cluster
MOCK_CLUSTER_NAME=mock-cluster
```
### Adding a New Metric
To add a new metric:
1. Add the metric to the Proxmox API client
2. Update the metrics processing service
3. Add the metric to the WebSocket payload
4. Create or update frontend components to display the metric
5. Add tests for the new functionality
### Adding a New Feature
When adding a new feature:
1. Create an issue describing the feature
2. Discuss the implementation approach
3. Implement backend changes
4. Implement frontend changes
5. Add tests
6. Update documentation
### Updating Screenshots
To update the screenshots in the documentation:
1. Make sure the application is running with the desired state
2. Run the screenshot script:
```bash
npm run screenshots
```
3. The screenshots will be saved to the `docs/images/` directory
## Troubleshooting
### Common Development Issues
- **WebSocket connection issues**: Check that both servers are running and ports are not blocked
- **Hot reloading not working**: Verify that the Vite dev server is running correctly
- **API errors**: Check the Proxmox API token permissions and server connectivity
- **Build errors**: Ensure all dependencies are installed and compatible
- **Mock data server not starting**: Check the logs at `/tmp/pulse-mock-server.log`
### Debugging Tips
- Use the browser developer tools to inspect network requests and WebSocket messages
- Check the server logs for backend errors
- Use React DevTools to inspect component state and props
- Set `LOG_LEVEL=debug` in your .env file for more verbose logging
- For mock data issues, check the mock server logs at `/tmp/pulse-mock-server.log`
### Killing Running Servers
If you need to kill running servers:
```bash
# Kill all development servers
npm run dev:kill:all
# Kill only backend servers
npm run dev:kill:backend
# Kill only frontend servers
npm run dev:kill:frontend
```
-194
View File
@@ -1,194 +0,0 @@
# Mock Data Documentation
This document provides information about the mock data functionality in Pulse, which is useful for development and testing without requiring a real Proxmox environment.
## Overview
The mock data system simulates:
- Multiple Proxmox nodes with realistic specifications
- Virtual machines (VMs) with various configurations
- Containers (CTs) with various configurations
- Realistic resource usage metrics that update in real-time
- Cluster mode functionality for testing multi-node environments
## Enabling Mock Data
To use mock data during development:
1. Set the following environment variables in your `.env` file:
```
NODE_ENV=development
USE_MOCK_DATA=true
MOCK_DATA_ENABLED=true
```
2. Start the development server with mock data:
```bash
# Local development with mock data
npm run dev
# Docker development with mock data
npm run dev:docker
```
These npm scripts automatically set the required environment variables for mock data.
## Mock Data Configuration
You can customize the mock data behavior with these environment variables:
```bash
# Number of mock nodes to generate
MOCK_NODE_COUNT=3
# Enable/disable mock cluster mode
MOCK_CLUSTER_ENABLED=true
# Custom name for the mock cluster
MOCK_CLUSTER_NAME=mock-cluster
# Number of VMs per node
MOCK_VM_COUNT=10
# Number of containers per node
MOCK_CT_COUNT=10
# Enable/disable random failures for testing error handling
MOCK_RANDOM_FAILURES=false
```
## Cluster Mode in Mock Data
The mock data system simulates Proxmox cluster environments by showing how VMs and containers exist across multiple nodes.
### How Shared Guests Work
In the mock data system:
- VMs and containers with the same VMID across different nodes are treated as separate entities
- The dashboard shows all instances of each VM/container, one for each node it exists on
- Each guest is associated with its specific node
- For shared guests (with IDs 999 and 888), the primary node concept is applied to prevent duplicate metrics
- Only one node (determined alphabetically) will show a shared guest as "running" and generate metrics for it
- Other nodes will show the shared guest as "stopped" with zeroed resource usage
This approach accurately represents how Proxmox works in reality:
- In a real Proxmox cluster, a VM or container exists in the cluster's configuration but is only actively running on one node at a time
- When a migration occurs, the VM/container stops on the source node and starts on the destination node
- Each node maintains its own view of the guests, showing which ones are running locally
### Shared Guests Behavior
Shared guests (with IDs 999 and 888) are handled specially:
1. **Primary Node Assignment**:
- The primary node for each shared guest is determined by sorting node IDs alphabetically
- The first node alphabetically becomes the primary node
- This ensures consistent behavior across application restarts
2. **Status and Metrics**:
- Shared guests appear on all nodes they exist on
- Only the primary node shows the guest as "running" and generates metrics
- Other nodes show the guest as "stopped" with zeroed resource usage
- This prevents duplicate metrics and provides a consistent experience
3. **Migrations**:
- Every 10 seconds, a shared guest (ID 999 or 888) may migrate to a different node
- The previous primary node will mark the guest as "stopped"
- The new primary node will mark the guest as "running" and start generating metrics
- Migrations are prioritized for shared guests to make the behavior more visible for testing
### Configuring Proxmox Cluster Detection
In real Proxmox environments, cluster detection is automatic. You can control this behavior through environment variables if needed:
```
# In your .env file
# Only set these if you want to override the automatic behavior:
PROXMOX_AUTO_DETECT_CLUSTER=false # Disable automatic cluster detection
PROXMOX_CLUSTER_NAME=my-cluster # Set a custom name for your cluster
```
### Testing Shared Guests
The mock data includes "shared" VMs and containers that exist on multiple nodes:
- A VM named "shared-vm" with ID 999 exists on all mock nodes
- A container named "shared-container" with ID 888 exists on all mock nodes
To test shared guests functionality:
1. Start the application with mock data enabled:
```bash
npm run dev
```
2. Observe how the shared VM and container appear on all nodes in the dashboard
3. Filter for "shared" in the search box to focus on just these resources
4. Verify that only one instance of each shared guest is shown as "running" (on the primary node)
5. Verify that other instances are shown as "stopped" with zeroed resource usage
6. Watch as they migrate between nodes every 10 seconds (primary node changes)
7. Observe in the server logs which node becomes primary:
```
🔄 MIGRATION: Guest 999 migrated from node node-1 to node-2
Guest 999 on node node-1: status=stopped (isPrimary=false)
Guest 999 on node node-2: status=running (isPrimary=true)
```
You can filter for these shared resources in the dashboard by typing "shared" in the search box. By default, the dashboard hides stopped guests, so you may need to toggle the "Show Stopped" option to see all instances of the shared guests.
## Customizing Mock Data
The mock data is generated in the `src/api/mock-client.ts` file. You can modify this file to:
- Change the number and specifications of nodes
- Adjust the number and types of VMs and containers
- Modify resource usage patterns
- Add or remove "shared" VMs and containers for cluster testing
- Change the update frequency of metrics
After modifying the mock data, restart the development server to apply your changes.
## Troubleshooting
If you encounter issues with mock data:
1. Ensure the environment variables are set correctly
2. Check the console logs for any errors related to mock data generation
3. Restart the development server to regenerate the mock data
4. Verify that your `.env` file has the correct settings
### Common Issues
#### Shared Guests Show Metrics on Multiple Nodes
If shared guests (ID 999 and 888) are showing metrics on multiple nodes:
1. Check the server logs for primary node assignment messages:
```
Found shared guest 999 on nodes: node-1, node-2, node-3
Assigned primary node node-1 for guest 999
```
2. Verify that the MockClient is respecting the primary node assignment:
```
Shared guest 999 (mock-shared-vm) on node node-2: primaryNode=node-1, shouldBeRunning=false
```
3. Ensure that the `initializePrimaryNodes` function is being called during server initialization
4. Check that migrations are working properly by watching for log messages like:
```
🔄 MIGRATION: Guest 999 migrated from node node-1 to node-2
Guest 999 on node node-1: status=stopped (isPrimary=false)
Guest 999 on node node-2: status=running (isPrimary=true)
```
#### Inconsistent Metrics for Shared Guests
If metrics for shared guests are inconsistent or fluctuating rapidly:
1. Ensure that only one node is generating metrics for each shared guest
2. Check that the primary node assignment is consistent across server restarts
3. Verify that the `isNodePrimaryForGuest` function is being called correctly
4. Monitor the logs for unexpected primary node changes
5. Note that shared guests (IDs 999 and 888) are configured to migrate every 10 seconds, so some fluctuation is expected
For more detailed information about development tools, see [README-dev-tools.md](../scripts/README-dev-tools.md).
-180
View File
@@ -1,180 +0,0 @@
# Pulse Release Guide
This document outlines the key requirements for creating releases of Pulse that work efficiently with the installation scripts.
## Release Criteria
Create releases for:
- New features
- Bug fixes
- Security updates
- Breaking changes
- Significant dependency updates affecting runtime behavior
## Release Requirements
### Version Consistency
Ensure version numbers match in ALL these files:
- `frontend/src/utils/version.js`
- `package.json`
- `frontend/package.json`
- `docker/Dockerfile` (LABEL version)
### Distribution Package Creation
For each release:
1. Build the application (both backend and frontend):
```bash
# Build backend
npm run build
# Build frontend
cd frontend && npm run build && cd ..
```
2. Create a distribution package (tar.gz) with the correct directory structure:
```bash
VERSION=$(node -e "console.log(require('./package.json').version)")
# Create temporary structure with files in the right locations
mkdir -p tmp/pulse/frontend
# Copy compiled backend, dependencies, and config files
cp -R dist node_modules package.json package-lock.json .env.example LICENSE README.md CHANGELOG.md scripts tmp/pulse/
cp scripts/start-prod.sh scripts/start-prod.bat tmp/pulse/
# Copy frontend files to frontend/dist (CRITICAL: server expects files here)
cp -R frontend/dist tmp/pulse/frontend/
# Include version info
echo "${VERSION}" > tmp/pulse/version.txt
# Create package without macOS metadata files
COPYFILE_DISABLE=1 tar --exclude="._*" --exclude=".git" --exclude=".DS_Store" -czf pulse-${VERSION}.tar.gz -C tmp pulse
# Clean up
rm -rf tmp
```
3. The package should exclude:
- Source TypeScript files
- Source maps
- Tests and test fixtures
- Build artifacts not needed for production
- Development dependencies
- Git files
- Operating system metadata files (._*)
> **IMPORTANT:** The directory structure is critical. The compiled server code expects frontend files to be in `frontend/dist/`. Placing them elsewhere (like `dist/public/`) will cause a "Frontend not found" error when the application starts.
### Docker Multi-Architecture Build
For each release:
1. Ensure Docker and Docker Buildx are properly configured:
```bash
docker buildx ls # Verify buildx is available
```
2. Login to DockerHub:
```bash
docker login
```
3. Build and push multi-architecture images:
```bash
VERSION=$(node -e "console.log(require('./package.json').version)")
docker buildx build --platform linux/amd64,linux/arm64 \
-t rcourtman/pulse:${VERSION} \
-t rcourtman/pulse:latest \
--push -f docker/Dockerfile .
```
4. Verify the multi-architecture image:
```bash
docker buildx imagetools inspect rcourtman/pulse:${VERSION}
```
5. Ensure both amd64 and arm64 architectures are included in the published image.
### CHANGELOG Generation
- Review ALL commits since the last release tag to create a comprehensive changelog
- Use `git log <last-tag>..HEAD --oneline` to see commits since the last release
- Ensure all significant changes, features, and fixes are documented
- Organize changes by type (Added, Changed, Fixed, etc.)
### Git and Release Steps
1. Update version numbers in all relevant files
2. Build backend and frontend
3. Create distribution package:
```bash
# Create distribution package
VERSION=$(node -e "console.log(require('./package.json').version)")
tar -czf pulse-${VERSION}.tar.gz -C dist pulse
```
4. Create GitHub release with:
- Version tag (vX.Y.Z)
- Release notes
- Distribution package attached
- CHANGELOG information
### Pre-Release Testing
Ensure:
- Distribution package installs and runs correctly
- Services start properly
- Configuration can be modified
- Application is accessible on expected port
- Mock data server works correctly
- Update process functions
- Docker image runs correctly on both amd64 and arm64 architectures
## Distribution Package Structure
The expected structure:
```
pulse/
├── dist/ # Compiled server JavaScript
├── frontend/
│ └── dist/ # Built frontend assets
├── node_modules/ # Production dependencies only
├── .env.example # Configuration template
├── package.json # For dependency references
├── LICENSE
├── README.md
└── version.txt # Plain text version for updates
```
## Common Issues
### Production vs Development
Ensure all code properly references the production paths. Common issues:
- Frontend asset paths
- API endpoint references
- Static file handling
### Mock Data Server
- Verify mock data server works in production mode
- Ensure it can be started correctly from systemd
- Test with mock data enabled and disabled
### Update Process Compatibility
Ensure new releases remain compatible with the update script in existing installations.
### Docker Build Problems
For multi-architecture builds, ensure buildx is configured properly and Docker daemon is running.
For ARM builds on x86 machines, ensure qemu emulation is available with: `docker run --rm --privileged multiarch/qemu-user-static --reset -p yes`
### Command Pager Issues
For commands that invoke a pager (git log, docker logs, etc.), append `| cat` to avoid interactive pager issues:
```
git log | cat
docker logs container_name | cat
```
-129
View File
@@ -1,129 +0,0 @@
# Release Process for ProxMox Pulse
This document outlines the process for creating new releases of the ProxMox Pulse application.
## Automated Release Process
We have an automated release script that handles most of the release process. The script:
1. Updates version numbers in all relevant files
2. Commits and pushes the changes
3. Creates and pushes a Git tag
4. Builds and pushes Docker images
5. Creates a GitHub release
### Prerequisites
Before running the release script, ensure you have:
- Git configured with appropriate credentials
- Docker installed and logged in to Docker Hub
- GitHub CLI (`gh`) installed and authenticated
- All changes you want to include in the release are committed
### Running the Release Script
To create a new release:
```bash
./scripts/release.sh <version>
```
For example:
```bash
./scripts/release.sh 1.0.13
```
The script will:
1. Check that you're on the main branch and have a clean working directory
2. Update version numbers in:
- `frontend/src/utils/version.js`
- `package.json`
- `frontend/package.json`
- `Dockerfile` labels
3. Commit and push these changes
4. Create and push a Git tag (e.g., `v1.0.13`)
5. Build Docker images for both the specific version and `latest`
6. Push the Docker images to Docker Hub
7. Create a GitHub release
### After Running the Script
After the script completes:
1. GitHub Actions workflows will be triggered automatically
2. The "Update Version" workflow will run first
3. Once that completes, the "Publish Docker image" workflow will run
You can check the status of these workflows with:
```bash
gh run list --limit 5
```
## Manual Release Process (if needed)
If you need to perform a release manually, follow these steps:
1. Update version numbers in:
- `frontend/src/utils/version.js`
- `package.json`
- `frontend/package.json`
- `Dockerfile` labels
2. Commit and push these changes:
```bash
git add frontend/src/utils/version.js package.json frontend/package.json Dockerfile
git commit -m "Bump version to X.Y.Z"
git push origin main
```
3. Create and push a Git tag:
```bash
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
```
4. Build Docker images:
```bash
docker build -t rcourtman/pulse:X.Y.Z -t rcourtman/pulse:latest .
```
5. Push Docker images:
```bash
docker push rcourtman/pulse:X.Y.Z
docker push rcourtman/pulse:latest
```
6. Create a GitHub release:
```bash
gh release create vX.Y.Z --title "Release vX.Y.Z" --notes "Release notes here"
```
## Troubleshooting
### GitHub Actions Workflows Not Running
If the GitHub Actions workflows don't run automatically:
1. Check the workflow files in `.github/workflows/`
2. Ensure they're configured to trigger on the appropriate events
3. Check the GitHub Actions tab in the repository for any errors
### Docker Build or Push Issues
If you encounter issues with Docker:
1. Ensure you're logged in to Docker Hub: `docker login`
2. Check that you have permissions to push to the repository
3. Verify that the Docker daemon is running
### Version Mismatch
If you notice version mismatches:
1. Check all files that contain version information
2. Ensure they're all updated to the same version
3. If necessary, make manual corrections and create a new patch release
-140
View File
@@ -1,140 +0,0 @@
# Screenshot Automation for Pulse for Proxmox VE
This document explains how to use the screenshot automation tool to keep the documentation images up-to-date with the latest UI changes.
## Quick Start
The easiest way to update screenshots is to use the provided npm script:
```bash
# From the project root
npm run screenshots
```
This script will:
1. Check if the development server is running with mock data enabled
2. Automatically start the server if needed (or restart it with mock data if it's running without mock data)
3. Run the screenshot tool with the default configuration
4. Save the screenshots to the `docs/images` directory
5. Clean up by stopping any servers it started
## Configuration
The screenshot tool is configured via a JSON file located at `tools/screenshot-automation/screenshot-config.json`. This file defines which screenshots to take, their sizes, and other options.
### Example Configuration
```json
{
"baseUrl": "http://localhost:7654",
"outputDir": "docs/images",
"screenshots": [
{
"path": "/",
"name": "dashboard",
"viewportSize": {
"width": 1920,
"height": 1080
},
"waitForSelector": ".dashboard-container",
"createSplitView": true,
"splitViewConfig": {
"type": "diagonal"
}
},
{
"path": "/resources",
"name": "resources",
"viewportSize": {
"width": 1920,
"height": 1080
},
"waitForSelector": ".resources-container",
"createSplitView": true,
"splitViewConfig": {
"type": "vertical"
}
}
]
}
```
## Adding New Screenshots
To add a new screenshot:
1. Open `tools/screenshot-automation/screenshot-config.json`
2. Add a new entry to the `screenshots` array:
```json
{
"path": "/your-page-path",
"name": "your-screenshot-name",
"waitForSelector": ".your-element-selector",
"createSplitView": true
}
```
3. Run `npm run screenshots` to generate the new screenshot
## Cropping Specific UI Elements
To capture just a specific part of the UI (e.g., a new feature):
```json
{
"path": "/",
"name": "feature-highlight",
"waitForSelector": ".feature-element",
"cropRegion": {
"x": 100,
"y": 200,
"width": 400,
"height": 300
}
}
```
## Split View (Light/Dark Mode)
The tool can create split-view images showing both light and dark modes:
```json
{
"path": "/settings",
"name": "settings",
"createSplitView": true,
"splitViewConfig": {
"type": "diagonal" // or "vertical"
}
}
```
## Advanced Usage
For more advanced usage, you can use the tool directly:
```bash
# From the project root
npm run screenshots:tool -- --config custom-config.json --url http://localhost:9000
```
Or from the tool directory:
```bash
cd tools/screenshot-automation
./update-screenshots.sh --help
```
## Troubleshooting
- **Error: Failed to build the screenshot tool**: Check for TypeScript errors in the tool code
- **Error: Waiting for selector timed out**: Ensure the selector exists on the page
- **Screenshots look wrong**: Check that the viewport size and selectors are correct
- **Server fails to start automatically**: You can manually start the server with `npm run dev:mock` before running the screenshot tool
- **Server doesn't shut down properly**: You can manually stop any running servers with `npm run dev:kill:all`
## Recommended Workflow
1. Make UI changes to the application
2. Run `npm run screenshots` to update all screenshots
3. Verify the screenshots look good
4. Commit the changes to the repository
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

-161
View File
@@ -1,161 +0,0 @@
# Logging in Pulse
This document describes the logging system in Pulse and how to use the logging tools to troubleshoot issues.
## Log Files
Pulse logs are stored in the `logs` directory:
- `combined.log`: The main log file containing all log entries
- `error.log`: Contains only error-level log entries
- `debug.log`: Contains debug-level log entries (when debug logging is enabled)
When the log files reach a certain size, they are rotated and numbered (e.g., `combined1.log`, `combined2.log`, etc.).
## Log Levels
Pulse uses the following log levels, in order of severity:
1. `error`: Critical errors that prevent the application from functioning correctly
2. `warn`: Warnings about potential issues that don't prevent the application from running
3. `info`: Informational messages about normal operation
4. `debug`: Detailed information useful for debugging
5. `silly`: Very detailed information (rarely used)
The log level can be configured in the `.env` file using the `LOG_LEVEL` variable.
## Logging Tools
Pulse includes several tools to help you monitor and troubleshoot logs:
### Log Monitor
The log monitor script (`scripts/monitor-logs.js`) allows you to view logs in real-time with filtering capabilities:
```bash
# Basic usage
node scripts/monitor-logs.js
# Filter by log level
node scripts/monitor-logs.js --level=error
# Filter by component
node scripts/monitor-logs.js --component=ProxmoxClient
# Search for specific text
node scripts/monitor-logs.js --search=cluster
# Combine filters
node scripts/monitor-logs.js --level=info --component=NodeManager
# Specify a log file
node scripts/monitor-logs.js --file=combined2.log
```
### Run with Logs
The run-with-logs script (`scripts/run-with-logs.js`) runs the application and monitors logs in the same terminal:
```bash
# Run in development mode with logs
node scripts/run-with-logs.js dev
# Run in production mode with logs
node scripts/run-with-logs.js prod
# Run with cluster mode disabled
node scripts/run-with-logs.js dev --no-cluster
# Run with log filtering
node scripts/run-with-logs.js prod --search=cluster
node scripts/run-with-logs.js dev --level=error
```
## NPM Scripts
For convenience, several npm scripts are provided for logging:
```bash
# Monitor logs
npm run logs:monitor
# Filter logs by level
npm run logs:errors
# Filter logs by component
npm run logs:proxmox
npm run logs:node-manager
# Search logs for cluster-related messages
npm run logs:cluster
# Run with logs
npm run dev:logs
npm run prod:logs
npm run dev:logs:no-cluster
npm run prod:logs:no-cluster
```
## Troubleshooting Common Issues
### Cluster Detection Issues
To troubleshoot cluster detection issues, use:
```bash
npm run logs:cluster
```
This will show all log messages related to cluster detection and configuration.
### Connection Issues
To troubleshoot connection issues with Proxmox servers, use:
```bash
npm run logs:proxmox
```
### Guest Duplication Issues
If you're seeing duplicated guests in the UI, check the cluster mode settings:
```bash
# Check if cluster mode is enabled
grep -i "cluster" logs/combined.log
# Run with cluster mode explicitly disabled
npm run dev:logs:no-cluster
```
### Performance Issues
To troubleshoot performance issues, monitor the debug logs:
```bash
npm run logs:monitor --level=debug
```
## Adding Logging to Your Code
When adding new code, follow these guidelines for logging:
1. Use the appropriate log level based on the severity of the message
2. Include relevant context in the log message
3. Use structured logging with metadata for complex objects
4. Include the component name in the logger
Example:
```typescript
import { createLogger } from '../utils/logger';
// Create a logger with a component name
const logger = createLogger('MyComponent');
// Log messages with different levels
logger.error('Critical error occurred', { error });
logger.warn('Potential issue detected', { details });
logger.info('Operation completed successfully');
logger.debug('Detailed debugging information', { data });
```
-114
View File
@@ -1,114 +0,0 @@
# Metrics Service
This document describes the metrics service in Pulse and how it handles data processing, rate calculation, and unrealistic rate detection.
## Overview
The metrics service is responsible for:
- Collecting raw metrics from Proxmox nodes
- Processing and calculating derived metrics
- Detecting and handling unrealistic rate values
- Maintaining historical data for trend analysis
- Broadcasting metrics updates to connected clients
## Rate Calculation
The metrics service calculates various rates from cumulative counters provided by Proxmox:
- Network in/out rates
- Disk read/write rates
- CPU usage rates
These calculations convert cumulative counters into rates by comparing the current value with the previous value and dividing by the time difference.
## Unrealistic Rate Handling
In some cases, Proxmox may report counter values that result in unrealistic rates when calculated. This can happen due to:
- Counter resets
- Proxmox API reporting inconsistencies
- Very short sampling intervals
- Network or disk bursts that exceed realistic hardware capabilities
To handle these cases, the metrics service implements rate capping:
```typescript
// Maximum realistic network rate (in bytes/second) - 125 MB/s (1 Gbps)
private readonly maxRealisticRate: number = 125 * 1024 * 1024;
// Apply sanity check to input rates - cap at maximum realistic rate
inRate = Math.min(inRate, this.maxRealisticRate);
outRate = Math.min(outRate, this.maxRealisticRate);
// In the calculateRate function
if (rate > this.maxRealisticRate) {
return this.maxRealisticRate;
}
```
When an unrealistic rate is detected:
1. The rate is silently capped at 125 MB/s (equivalent to 1 Gbps)
2. The capped value is used for display and calculations
3. This prevents UI graphs from showing unrealistic spikes
## Rate Smoothing
To provide a more stable and realistic view of network and disk activity, the metrics service implements rate smoothing:
1. **Moving Average**: Rates are smoothed using a simple moving average over the last few samples
2. **Spike Detection**: Sudden large increases in rates are detected and smoothed
3. **Calibration**: The service gradually calibrates to stable rates over time
## Configuration
The metrics service behavior can be configured through environment variables:
```
# Maximum history length (number of data points to keep)
METRICS_HISTORY_LENGTH=100
# Polling interval in milliseconds
METRICS_POLLING_INTERVAL=2000
# Maximum realistic rate in MB/s (default: 125 MB/s = 1 Gbps)
# Increase this value for faster networks:
# - 1250 for 10 Gbps networks
# - 3125 for 25 Gbps networks
# - 12500 for 100 Gbps networks
METRICS_MAX_REALISTIC_RATE=125
```
## Performance Considerations
The rate capping mechanism ensures that the UI displays reasonable values and prevents graphs from being skewed by unrealistic spikes. This is particularly important when:
- A VM or container has a sudden burst of network or disk activity
- The Proxmox API reports counter values that result in unrealistic rates
- The sampling interval is very short, magnifying small counter differences
If you have faster network hardware, you should adjust the rate capping behavior:
```bash
# Example: Set max rate to 1250 MB/s for a 10 Gbps network
METRICS_MAX_REALISTIC_RATE=1250
# Example: Set max rate to 3125 MB/s for a 25 Gbps network
METRICS_MAX_REALISTIC_RATE=3125
# Example: Set max rate to 12500 MB/s for a 100 Gbps network
METRICS_MAX_REALISTIC_RATE=12500
```
You can also adjust these other parameters:
1. Increase the polling interval to reduce the chance of catching short bursts
2. Adjust the maximum realistic rate if your hardware supports higher rates
## Advanced Usage
For advanced users who want to customize the metrics service behavior, you can modify the following parameters in the source code:
- `movingAverageSamples`: Number of samples to use for the moving average
- `maxRateDeviation`: Maximum allowed deviation for spike detection
- `speedIncreaseBias`: Bias factor for increasing speeds
- `speedDecreaseBias`: Bias factor for decreasing speeds
- `stabilityThreshold`: Percentage variation allowed for a rate to be considered stable
- `stabilityCounter`: Number of consecutive samples within threshold to consider a rate stable
- `calibrationFactor`: How much to weight new values vs. calibrated value
-190
View File
@@ -1,190 +0,0 @@
# Proxmox HA Status and Primary Node Indication
This document describes how the Proxmox VE API indicates whether a guest VM or container is "primary" or not through its cluster/resources endpoint.
## Overview
In Proxmox VE clusters, virtual machines and containers can be managed by the High Availability (HA) system. When querying the API to determine the status of resources across a cluster, understanding how to interpret the response is critical for proper cluster management.
## The `/cluster/resources` Endpoint
The `/cluster/resources` endpoint is a key API endpoint that provides information about all resources in a Proxmox cluster, including VMs, containers, storage, and nodes.
### Example Response
```json
{
"data": [
{
"cpu": 0.01,
"disk": 0,
"diskread": 0,
"diskwrite": 0,
"hastate": "started", // HA state indicator
"id": "qemu/100",
"maxcpu": 2,
"maxdisk": 32212254720,
"maxmem": 2147483648,
"mem": 315621376,
"name": "VM-100",
"netin": 524,
"netout": 4112,
"node": "node1", // Which node is hosting the resource
"status": "running", // Current operational status
"template": 0,
"type": "qemu",
"uptime": 3612,
"vmid": 100
}
]
}
```
## HA States and Primary Node Status
In Proxmox terminology, the concept of a "primary" node for a VM relates to which node is actively hosting and running that VM. This is determined by a combination of fields in the API response:
### Critical Fields:
1. **`hastate`**: Indicates the High Availability state of the resource
2. **`node`**: Indicates which node is currently hosting the resource
3. **`status`**: Indicates the operational status of the resource
### Possible `hastate` Values:
- **`started`**: The resource is running and managed by HA
- **`stopped`**: The resource is stopped but still managed by HA for failover
- **`disabled`**: The resource is not currently managed by HA for failover
- **`ignored`**: The resource is explicitly removed from HA management
- **`error`**: The resource is in an error state
- **`migrate`**: The resource is being migrated between nodes
- **`relocate`**: The resource is being relocated (cold migration)
- **`fence`**: The node needs to be fenced to recover the resource
- **`recovery`**: The resource is in the recovery process
### Determining Primary Status
To determine if a VM is "primary" on a specific node, you would check:
1. The VM's `node` field shows the node you're interested in
2. The VM's `hastate` field is `started` (or present, depending on HA configuration)
3. The VM's `status` field is `running`
A VM would be considered "primary" when it is actively running on the specified node, as indicated by these fields.
## Additional HA-Related Endpoints
Proxmox provides additional endpoints specifically for HA management:
### 1. `/cluster/ha/resources`
This endpoint returns information about resources managed by the HA system:
```json
{
"data": [
{
"sid": "vm:100",
"type": "vm",
"status": "started",
"state": "started",
"digest": "abc123...",
"max_restart": 1,
"max_relocate": 1,
"group": "default"
}
]
}
```
### 2. `/cluster/ha/status`
This endpoint returns the current status of the HA cluster:
```json
{
"data": [
{
"type": "cluster",
"name": "Proxmox HA Cluster",
"quorate": true,
"enabled": true
},
{
"id": "node1",
"name": "node1",
"type": "node",
"status": "online",
"quorum": true
},
{
"state": "started",
"sid": "vm:100",
"node": "node1",
"crm_state": "started",
"request_state": "started"
}
]
}
```
## Proxmox HA Implementation Details
Proxmox VE implements high availability through the `ha-manager`, which consists of two main components:
1. **Cluster Resource Manager (CRM)**: Makes cluster-wide decisions about resource placement
2. **Local Resource Manager (LRM)**: Controls services on each individual node
When a VM is configured for HA:
- It appears in the `/etc/pve/ha/resources.cfg` file
- The HA system monitors its status and handles failovers
- It's assigned an HA state that is exposed through the API via the `hastate` field
During VM migrations in an HA-managed cluster:
1. The VM's `hastate` changes to `migrate` on the source node
2. After migration, the source node marks the VM as `stopped`
3. The destination node changes the VM's `hastate` to `started` once migration completes
4. This ensures only one node runs the VM at a time, preventing split-brain situations
## Mock Implementation
Our mock implementation now accurately simulates Proxmox's HA behavior:
1. **HA State Tracking**: Each VM has a `hastate` field that corresponds to its HA management status
2. **Migration Simulation**: When VMs migrate between nodes, their `hastate` is updated through realistic state transitions
3. **Shared Guests**: VMs that exist across multiple nodes (shared guests) maintain consistent `hastate` values:
- Only the primary node shows the guest with `hastate: "started"`
- Other nodes show it with `hastate: "stopped"`
4. **API Consistency**: Multiple endpoints are supported to match a real Proxmox environment:
- `/cluster/resources` - Shows all resources with their HA states
- `/cluster/ha/resources` - Shows just the HA-managed resources
- `/cluster/ha/status` - Shows detailed HA status
5. **Realistic Migrations**: The migration process shows the actual state transitions a VM would go through in a real environment
6. **Error States**: Occasional error states are generated to test application resilience
This implementation ensures that your application can properly handle HA status information whether it's connected to a real Proxmox cluster or using the mock data for development.
## Implementation Considerations
When implementing cluster management tools that interact with Proxmox:
1. Always check the `hastate` field to understand the HA management status
2. Don't rely solely on the `status` field, as a VM might be in a transitional state
3. Consider that VMs may migrate between nodes in an HA cluster
4. HA-managed resources will follow the configured HA policies
5. Handle all possible `hastate` values, including error states and transitional states like `migrate`
## Caveats
- HA status may change during cluster maintenance or failures
- The API response might reflect a transitional state during migrations
- Not all VMs/containers will have an `hastate` if they're not managed by HA
- Different Proxmox versions might have slight variations in the `hastate` values and behaviors
## References
- [Proxmox VE API Documentation](https://pve.proxmox.com/pve-docs/api-viewer/index.html)
- [Proxmox HA Manager Documentation](https://pve.proxmox.com/pve-docs/ha-manager.1.html)
- [Proxmox HA Resources Configuration](https://pve.proxmox.com/wiki/High_Availability)
-172
View File
@@ -1,172 +0,0 @@
# Troubleshooting WebSocket Connection Issues
If you're experiencing the "Connection error: websocket error" message when using Pulse, this guide will help you resolve the issue.
## Understanding the Problem
The WebSocket connection error typically occurs when:
1. The frontend client is trying to connect to `localhost` or `127.0.0.1` instead of the actual server IP
2. There's a network configuration issue preventing WebSocket connections
3. Docker networking is interfering with the WebSocket connection
4. A reverse proxy is not properly configured for WebSocket traffic
## Quick Fixes (Secure Options)
Try these solutions in order:
### 1. Use the latest version of Pulse
Make sure you're using Pulse version 1.5.2 or later, which includes fixes for WebSocket connection issues:
```bash
# Using Docker Compose
docker pull rcourtman/pulse:latest
docker compose down
docker compose up -d
# Using npm scripts
npm run update
npm run restart
```
### 2. Check the logs for WebSocket connection issues
Pulse includes powerful logging tools to help diagnose WebSocket connection issues:
```bash
# View all logs
npm run logs
# View WebSocket-specific logs
npm run logs:websocket
# Filter logs for connection issues
node scripts/monitor-logs.js --search="websocket"
```
Look for messages related to WebSocket connections, particularly errors or warnings.
### 3. Remove VITE_API_URL from your .env file
If you've set `VITE_API_URL` in your `.env` file, try removing it completely. Pulse is designed to automatically determine the correct connection URL.
### 4. Access Pulse directly by IP address
Instead of using a domain name or localhost, try accessing Pulse directly by its IP address:
```
http://192.168.x.x:7654
```
### 5. Check your reverse proxy configuration
If you're using a reverse proxy (like Nginx or Apache), make sure it's properly configured for WebSocket connections:
#### Nginx Configuration
```nginx
location /socket.io {
proxy_pass http://your-pulse-server:7654;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location / {
proxy_pass http://your-pulse-server:7654;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
#### Apache Configuration
```apache
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/socket.io [NC]
RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://your-pulse-server:7654/$1 [P,L]
ProxyPass / http://your-pulse-server:7654/
ProxyPassReverse / http://your-pulse-server:7654/
```
## Last Resort: Host Network Mode
If none of the above solutions work, you can use Docker's host network mode as a last resort. Note that this has security implications as it gives the container full access to the host's network stack.
```yaml
# In your docker-compose.yml
services:
pulse:
image: rcourtman/pulse:latest
# ... other settings ...
network_mode: "host"
```
**Security Considerations with Host Network Mode:**
- The container shares the host's entire network namespace
- It can bind to any port on the host
- It has access to all host network interfaces
- It bypasses Docker's network isolation
- It increases the attack surface if the container is compromised
Only use this option if you understand these risks and have no other choice.
## Debugging the Connection
To debug the WebSocket connection:
1. Open your browser's Developer Tools (F12)
2. Go to the Network tab
3. Filter for "WS" or "WebSocket"
4. Reload the page
5. Look for connection attempts to `/socket.io`
If you see failed connection attempts to `ws://localhost:7654` or `ws://127.0.0.1:7654` when you're accessing Pulse from a different IP, that's the root of the problem.
You can also use the built-in connection checker tool:
```bash
node scripts/check-connections.js
```
This will test various connection methods and help identify the issue.
## Advanced Solutions
### For Docker Users
Try these alternatives to host network mode:
1. Use the latest version of Pulse (1.5.2+) which includes fixes for WebSocket connection issues
2. Explicitly set the container's IP in your `.env` file:
```
VITE_API_URL=http://your-server-ip:7654
```
3. Make sure port 7654 is properly exposed in your Docker configuration
4. Consider using Docker's macvlan network driver for advanced networking needs
### For Kubernetes Users
If you're running Pulse in Kubernetes:
1. Make sure your Service and Ingress are configured to handle WebSocket connections
2. Set the proper annotations for WebSocket support on your Ingress
3. Use a LoadBalancer or NodePort service type to expose the WebSocket endpoint
## Still Having Issues?
If you're still experiencing WebSocket connection problems after trying these solutions:
1. Check your browser console for specific error messages
2. Look at the Docker logs: `docker logs pulse`
3. Try a different browser or device
4. Ensure no firewall is blocking WebSocket connections on port 7654
For additional help, please open an issue on GitHub with:
- Your exact configuration
- Browser console logs
- Docker logs
- Network environment details
-56
View File
@@ -1,56 +0,0 @@
# Environment Configuration
Pulse uses a single `.env` file for all environment settings related to Proxmox and application configuration.
## Required Variables
```bash
# Required Proxmox Configuration
PROXMOX_HOST=https://your-proxmox-host:8006
PROXMOX_NODE=your-node-name
PROXMOX_TOKEN_ID=your-token-id
PROXMOX_TOKEN_SECRET=your-token-secret
```
## Optional Variables
```bash
# Optional: For multiple nodes
PROXMOX_HOST_2=https://your-second-proxmox-host:8006
PROXMOX_NODE_2=your-second-node-name
PROXMOX_TOKEN_ID_2=your-second-token-id
PROXMOX_TOKEN_SECRET_2=your-second-token-secret
# Optional: SSL Configuration
# If you have SSL certificate issues, uncomment these lines:
# IGNORE_SSL_ERRORS=true
# NODE_TLS_REJECT_UNAUTHORIZED=0
# Optional: Application Configuration
NODE_ENV=production # 'production' or 'development'
USE_MOCK_DATA=false # Set to true to use mock data instead of connecting to Proxmox
PORT=7654 # Port for the backend server
LOG_LEVEL=info # Logging level (debug, info, warn, error)
```
## Example Files
An example file is provided as a template:
- `.env.example` - Example configuration file
## Usage
Copy the `.env.example` file to create your actual environment file:
```bash
cp .env.example .env
```
Then edit the `.env` file with your specific settings.
## Security Note
⚠️ **IMPORTANT**: The `.env` file contains sensitive information such as API tokens and should NEVER be committed to the repository.
The `.gitignore` file is configured to exclude this sensitive file from being committed.
-81
View File
@@ -1,81 +0,0 @@
# Pulse Frontend
A React-based frontend for real-time Proxmox VE monitoring with socket.io for live data updates.
## Features
- Real-time network data monitoring
- WebSocket communication with the backend
- Responsive Material UI design
- Automatic reconnection handling
- Cluster-aware VM and container display
- Customizable filters and views
## Requirements
- Node.js 18.x or higher
- npm or yarn
## Installation
1. Install dependencies:
```bash
npm install
# or
yarn
```
2. Configure environment (if needed):
The frontend automatically connects to the backend on the same host and port. In most cases, you don't need to configure anything.
If you need to override the default settings, create a `.env` file in the root directory with:
```
VITE_API_URL=http://your-backend-url:7654
```
## Development
To start the development server:
```bash
# From the project root (recommended)
npm run dev
# Or from the frontend directory
npm run dev
```
This will start a development server at http://localhost:7654
The development server is configured to proxy API requests to the backend server running on port 7655.
## Building for Production
To create a production build:
```bash
npm run build
```
This will generate optimized files in the `dist` directory.
## Troubleshooting
If you're experiencing issues with real-time updates:
1. Check that the WebSocket server is running
2. Ensure your browser supports WebSockets
3. Check the network tab in your developer tools for any connection errors
4. Try accessing the application by IP address instead of localhost
5. Check the logs for WebSocket connection issues:
```bash
npm run logs:websocket
```
6. Use the connection checker tool:
```bash
node scripts/check-connections.js
```
For more detailed troubleshooting, see the [WebSocket Troubleshooting Guide](../docs/troubleshooting-websocket.md).
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/logos/pulse-logo-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/logos/pulse-logo-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/logos/pulse-logo-128x128.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Pulse - Real-time monitoring dashboard for Proxmox VE with live updates" />
<title>Pulse</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-3655
View File
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
{
"name": "pulse-frontend",
"version": "1.6.4",
"private": true,
"main": "index.js",
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "vite --port 3000",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@babel/helpers": "^7.26.10",
"@babel/runtime": "^7.26.10",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.6",
"@mui/material": "^6.4.6",
"@mui/x-tree-view": "^7.26.0",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"axios": "^1.8.3",
"notistack": "^3.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0",
"recharts": "^2.15.1",
"socket.io-client": "^4.8.1",
"vite": "^6.2.2"
},
"devDependencies": {
"concurrently": "^9.1.2",
"kill-port": "^2.0.1"
}
}
-43
View File
@@ -1,43 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<!-- Gradient definitions to match the app logo -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(58,123,213,0.8)" />
<stop offset="100%" stop-color="rgba(58,123,213,0.7)" />
</linearGradient>
<linearGradient id="ringGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.2)" />
<stop offset="100%" stop-color="rgba(255,255,255,0.1)" />
</linearGradient>
<linearGradient id="centerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="#f0f0f0" />
</linearGradient>
<!-- Glow filter for the center dot -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Background circle with gradient -->
<circle cx="16" cy="16" r="16" fill="url(#bgGradient)" />
<!-- Outer ring with gradient -->
<circle cx="16" cy="16" r="15" fill="url(#ringGradient)" />
<!-- Pulse ring -->
<circle cx="16" cy="16" r="11" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="1.8" />
<!-- Center dot with gradient and glow -->
<circle cx="16" cy="16" r="5" fill="url(#centerGradient)" filter="url(#glow)" />
<!-- Dark mode support -->
<style>
@media (prefers-color-scheme: dark) {
#bgGradient stop:first-child { stop-color: rgba(58,123,213,0.9); }
#bgGradient stop:last-child { stop-color: rgba(58,123,213,0.8); }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

-428
View File
@@ -1,428 +0,0 @@
import React, { useState, useEffect, Suspense, lazy } from 'react';
import {
Container,
Box,
Typography,
AppBar,
Toolbar,
Paper,
IconButton,
Tooltip,
alpha,
Button,
Link,
Chip,
Alert,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Switch,
Divider
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import GitHubIcon from '@mui/icons-material/GitHub';
import SettingsIcon from '@mui/icons-material/Settings';
import NetworkDisplay from './components/NetworkDisplay';
import { AppThemeProvider, useThemeContext } from './context/ThemeContext';
import { SearchProvider } from './context/SearchContext';
import { UserSettingsProvider, useUserSettings } from './context/UserSettingsContext';
import SearchField from './components/SearchField';
import NodeSelect from './components/NodeSelect';
import useSocket from './hooks/useSocket';
import { VERSION } from './utils/version';
import { AnimatedLogoWithText } from './components/network/UIComponents';
import { initializeStorage } from './utils/storageUtils';
import { SnackbarProvider } from 'notistack';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
function AppContent() {
// Check if we're in development mode
const isDevelopment = import.meta.env.DEV;
// Initialize local storage with default values if needed
useEffect(() => {
initializeStorage();
}, []);
// Initialize storage based on environment
useEffect(() => {
// Check environment variables to decide if mock data should be enabled
const envUseMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true';
const envMockDataEnabled = import.meta.env.VITE_MOCK_DATA_ENABLED === 'true';
console.log(`Environment variables: USE_MOCK_DATA=${envUseMockData}, MOCK_DATA_ENABLED=${envMockDataEnabled}`);
// Only use server-side mock data implementation
if (isDevelopment) {
if (envUseMockData || envMockDataEnabled) {
console.log('Setting server-side mock data flags based on environment variables');
localStorage.setItem('use_mock_data', 'true');
localStorage.setItem('MOCK_DATA_ENABLED', 'true');
// Clean up any client-side mock data settings
localStorage.removeItem('mock_enabled');
localStorage.removeItem('MOCK_SERVER_URL');
localStorage.removeItem('MOCK_DATA');
// Ensure we're not setting window globals for client-side mocking
if (window.MOCK_DATA) {
delete window.MOCK_DATA;
}
} else {
console.log('Disabling mock data flags based on environment variables');
localStorage.removeItem('use_mock_data');
localStorage.removeItem('MOCK_DATA_ENABLED');
localStorage.removeItem('mock_enabled');
localStorage.removeItem('MOCK_SERVER_URL');
localStorage.removeItem('MOCK_DATA');
}
} else {
// In production, always ensure mock data is disabled
if (localStorage.getItem('use_mock_data') === 'true' || localStorage.getItem('MOCK_DATA_ENABLED') === 'true') {
console.log('Resetting mock data flags to match production environment');
localStorage.removeItem('use_mock_data');
localStorage.removeItem('MOCK_DATA_ENABLED');
localStorage.removeItem('mock_enabled');
localStorage.removeItem('MOCK_SERVER_URL');
localStorage.removeItem('MOCK_DATA');
}
}
}, [isDevelopment]);
const theme = useTheme();
const { status, connected, isMockData } = useSocket();
// Get theme context functions
const { darkMode, toggleDarkMode, showOnlyRunning, toggleShowOnlyRunning } = useUserSettings();
// State for node selection
const [selectedNode, setSelectedNode] = useState('all');
// Listen for node change events from other components
useEffect(() => {
const handleNodeChange = (event) => {
if (event.detail && event.detail.node) {
setSelectedNode(event.detail.node);
}
};
window.addEventListener('nodeChange', handleNodeChange);
return () => {
window.removeEventListener('nodeChange', handleNodeChange);
};
}, []);
// State for settings menu
const [settingsAnchorEl, setSettingsAnchorEl] = useState(null);
const isSettingsMenuOpen = Boolean(settingsAnchorEl);
// Settings menu handlers
const handleSettingsClick = (event) => {
setSettingsAnchorEl(event.currentTarget);
};
const handleSettingsClose = () => {
setSettingsAnchorEl(null);
};
// Check if mock data is enabled - using both localStorage values and socket status
const isMockDataEnabled = isMockData ||
localStorage.getItem('use_mock_data') === 'true' ||
localStorage.getItem('MOCK_DATA_ENABLED') === 'true' ||
import.meta.env.VITE_FORCE_MOCK_DATA === 'true';
// Generate content based on connection status
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
color: 'text.primary',
}}
>
{/* DEMO MODE Banner - Only visible when mock data is enabled */}
{(isMockDataEnabled) && (
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
py: 0.5,
bgcolor: theme.palette.mode === 'dark'
? 'rgba(255, 152, 0, 0.7)'
: 'rgba(255, 152, 0, 0.85)',
color: '#fff',
fontWeight: 500,
'& .MuiAlert-icon': {
color: '#fff',
alignItems: 'center'
},
'& .MuiAlert-message': {
width: '100%',
textAlign: 'center',
fontSize: '0.95rem',
textShadow: '0 1px 1px rgba(0,0,0,0.15)'
}
}}
>
Demo Mode - Running with simulated data
</Alert>
)}
<AppBar position="static" color="primary" elevation={0}>
<Toolbar sx={{ px: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<AnimatedLogoWithText darkMode={darkMode} />
{/* VERSION tag */}
<Chip
size="small"
label={`v${VERSION}`}
sx={{
ml: 2,
fontSize: '0.75rem',
height: 24,
borderRadius: 3,
bgcolor: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.3 : 0.15),
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark,
}}
/>
</Box>
{/* Right-aligned actions */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 'auto' }}>
{/* Search field */}
<SearchField />
{/* Node selection button */}
<NodeSelect
selectedNode={selectedNode}
setSelectedNode={setSelectedNode}
/>
{/* Column Visibility Button - Add reference to NetworkDisplay to access this */}
<Box sx={{ display: { xs: 'none', sm: 'flex' }, mx: 1 }} id="column-visibility-app-header">
{/* This Box will be populated by NetworkDisplay with the column visibility button */}
</Box>
{/* GitHub link */}
<Tooltip title="GitHub Repository">
<IconButton
color="inherit"
aria-label="github"
href="https://github.com/rcourtman/pulse"
target="_blank"
component="a"
size="small"
sx={{ ml: 0.5 }}
>
<GitHubIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Settings menu */}
<Tooltip title="Settings">
<IconButton
id="settings-button"
aria-controls={isSettingsMenuOpen ? 'settings-menu' : undefined}
aria-haspopup="true"
aria-expanded={isSettingsMenuOpen ? 'true' : undefined}
onClick={handleSettingsClick}
color="inherit"
size="small"
sx={{ ml: 0.5 }}
>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
<Menu
id="settings-menu"
anchorEl={settingsAnchorEl}
open={isSettingsMenuOpen}
onClose={handleSettingsClose}
MenuListProps={{
'aria-labelledby': 'settings-button',
dense: true,
}}
PaperProps={{
elevation: 3,
sx: {
mt: 1.5,
minWidth: 200,
borderRadius: 2,
}
}}
>
<MenuItem
onClick={toggleDarkMode}
sx={{ py: 1 }}
>
<ListItemIcon>
{darkMode ? <Brightness7Icon fontSize="small" /> : <Brightness4Icon fontSize="small" />}
</ListItemIcon>
<ListItemText>
{darkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
</ListItemText>
<Switch
edge="end"
checked={darkMode}
size="small"
onClick={(e) => {
e.stopPropagation();
toggleDarkMode();
}}
/>
</MenuItem>
<Divider />
{/* Show only running guests toggle */}
<MenuItem
onClick={toggleShowOnlyRunning}
sx={{ py: 1 }}
>
<ListItemIcon>
<PlayArrowIcon fontSize="small" color={showOnlyRunning ? "primary" : "inherit"} />
</ListItemIcon>
<ListItemText
primary="Show Only Running"
secondary={showOnlyRunning ? "Hiding stopped guests" : "Showing all guests"}
/>
<Switch
edge="end"
checked={showOnlyRunning}
size="small"
onClick={(e) => {
e.stopPropagation();
toggleShowOnlyRunning();
}}
color="primary"
/>
</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
<Container
maxWidth={false}
sx={{
mt: { xs: 0.5, sm: 0.5 },
mb: { xs: 2, sm: 4 },
flexGrow: 1,
px: { xs: 1, sm: 2 },
width: '100%',
mx: 'auto'
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
document.activeElement?.blur();
}
}}
>
<Box sx={{ my: { xs: 0.5, sm: 0.5 } }}>
{/* Network Display Component - pass the selected node as a prop */}
<NetworkDisplay selectedNode={selectedNode} />
</Box>
</Container>
<Paper
sx={{
padding: 2,
marginTop: 'auto',
borderRadius: 0,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
}}
component="footer"
elevation={0}
>
<Typography variant="body2" color="text.secondary" align="center">
Pulse © {new Date().getFullYear()}
</Typography>
</Paper>
</Box>
);
}
function App() {
useEffect(() => {
// Sync important environment variables to localStorage
if (import.meta.env.VITE_MOCK_CLUSTER_ENABLED !== undefined) {
localStorage.setItem('MOCK_CLUSTER_ENABLED', import.meta.env.VITE_MOCK_CLUSTER_ENABLED);
}
if (import.meta.env.VITE_MOCK_DATA_ENABLED !== undefined) {
localStorage.setItem('MOCK_DATA_ENABLED', import.meta.env.VITE_MOCK_DATA_ENABLED);
}
if (import.meta.env.VITE_PROXMOX_AUTO_DETECT_CLUSTER !== undefined) {
localStorage.setItem('PROXMOX_AUTO_DETECT_CLUSTER', import.meta.env.VITE_PROXMOX_AUTO_DETECT_CLUSTER);
}
// Check for cluster status from server directly when application loads
fetch('/api/cluster/status')
.then(response => response.json())
.then(data => {
const hasCluster = data?.hasCluster || false;
console.log('Initial cluster status check:', { hasCluster, data });
// Update localStorage based on actual server detection
localStorage.setItem('CLUSTER_DETECTED', hasCluster ? 'true' : 'false');
// If this is a non-cluster environment, force remove column visibility setting
// This ensures the HA column is hidden
if (!hasCluster) {
// Clear the column visibility to reset to defaults
localStorage.removeItem('network_display_column_visibility');
// Set an explicit column visibility object with role hidden
const defaultVisibility = {
name: { id: 'name', label: 'Name', visible: true },
status: { id: 'status', label: 'Status', visible: true },
node: { id: 'node', label: 'Node', visible: false },
role: { id: 'role', label: 'HA Status', visible: false },
type: { id: 'type', label: 'Type', visible: true },
id: { id: 'id', label: 'ID', visible: true },
cpu: { id: 'cpu', label: 'CPU', visible: true },
memory: { id: 'memory', label: 'Memory', visible: true },
disk: { id: 'disk', label: 'Disk', visible: true },
download: { id: 'download', label: 'Download', visible: true },
upload: { id: 'upload', label: 'Upload', visible: true },
uptime: { id: 'uptime', label: 'Uptime', visible: true }
};
// Save this explicit setting to localStorage
localStorage.setItem('network_display_column_visibility', JSON.stringify(defaultVisibility));
console.log('Non-cluster environment detected, forced HA Status column to hide');
}
// Store current status for future comparison
localStorage.setItem('previous_cluster_status', hasCluster ? 'true' : 'false');
})
.catch(error => {
console.warn('Unable to check cluster status on load:', error);
// Default to no cluster if server check fails
localStorage.setItem('CLUSTER_DETECTED', 'false');
});
}, []);
return (
<AppThemeProvider>
<SearchProvider>
<UserSettingsProvider>
<SnackbarProvider maxSnack={3}>
<AppContent />
</SnackbarProvider>
</UserSettingsProvider>
</SearchProvider>
</AppThemeProvider>
);
}
export default App;
-59
View File
@@ -1,59 +0,0 @@
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log the error to the console
console.error('Error caught by ErrorBoundary:', error, errorInfo);
this.setState({ errorInfo });
}
render() {
if (this.state.hasError) {
// Render fallback UI
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
p: 3,
textAlign: 'center'
}}>
<Typography variant="h4" color="error" gutterBottom>
Something went wrong
</Typography>
<Typography variant="body1" paragraph>
{this.state.error?.message || 'An unknown error occurred'}
</Typography>
<Typography variant="body2" paragraph>
Try refreshing the page or check the console for more details.
</Typography>
<Button
variant="contained"
color="primary"
onClick={() => window.location.reload()}
>
Refresh Page
</Button>
</Box>
);
}
// If there's no error, render the children
return this.props.children;
}
}
export default ErrorBoundary;
@@ -1,2 +0,0 @@
import NetworkDisplay from './network/NetworkDisplay';
export default NetworkDisplay;
-184
View File
@@ -1,184 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Box,
Typography,
Tooltip
} from '@mui/material';
import ComputerIcon from '@mui/icons-material/Computer';
import ViewListIcon from '@mui/icons-material/ViewList';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckIcon from '@mui/icons-material/Check';
import useSocket from '../hooks/useSocket';
const NodeSelect = ({ selectedNode = 'all', setSelectedNode }) => {
const [nodeMenuAnchorEl, setNodeMenuAnchorEl] = useState(null);
const openNodeMenu = Boolean(nodeMenuAnchorEl);
// Get node data from socket connection
const { nodeData, guestData } = useSocket();
// Prepare available nodes for dropdown
const availableNodes = useMemo(() => {
// Start with the "All Nodes" option
const nodes = [
{ id: 'all', name: 'All Nodes', count: 0 }
];
// Add actual nodes if they exist
if (nodeData && nodeData.length > 0) {
// Add real nodes from the API
nodeData.forEach(node => {
nodes.push({
id: node.id,
name: node.name || node.id,
count: 0
});
});
}
// If no node data, try to extract nodes from guest data
else if (guestData && guestData.length > 0) {
// Collect all unique node names from guests
const nodeSet = new Set();
guestData.forEach(guest => {
if (guest.node) {
nodeSet.add(guest.node);
}
});
// Create node objects from the unique node names
Array.from(nodeSet).forEach(nodeName => {
nodes.push({
id: nodeName,
name: nodeName,
count: 0
});
});
}
return nodes;
}, [nodeData, guestData]);
// Handle node menu button click
const handleNodeMenuClick = (event) => {
setNodeMenuAnchorEl(event.currentTarget);
};
// Handle node menu close
const handleNodeMenuClose = () => {
setNodeMenuAnchorEl(null);
};
// Handle node selection
const handleNodeSelect = (nodeId) => {
setSelectedNode(nodeId);
handleNodeMenuClose();
};
// Get the name of the currently selected node
const selectedNodeName = useMemo(() => {
const node = availableNodes.find(n => n.id === selectedNode);
return node ? node.name : selectedNode;
}, [availableNodes, selectedNode]);
return (
<>
<Tooltip title="Filter by node">
<Button
id="node-filter-button"
aria-controls={openNodeMenu ? 'node-filter-menu' : undefined}
aria-haspopup="true"
aria-expanded={openNodeMenu ? 'true' : undefined}
onClick={handleNodeMenuClick}
endIcon={<ExpandMoreIcon />}
color="inherit"
size="small"
sx={{
ml: 1,
textTransform: 'none',
fontSize: '0.85rem'
}}
>
{selectedNodeName}
</Button>
</Tooltip>
<Menu
id="node-filter-menu"
anchorEl={nodeMenuAnchorEl}
open={openNodeMenu}
onClose={handleNodeMenuClose}
MenuListProps={{
'aria-labelledby': 'node-filter-button',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
overflow: 'hidden',
width: 200,
maxHeight: 300
}
}}
>
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
Filter by Node
</Typography>
</Box>
<Box sx={{ maxHeight: '60vh', overflow: 'auto' }}>
{availableNodes.map((node) => (
<MenuItem
key={node.id}
value={node.id}
onClick={() => handleNodeSelect(node.id)}
sx={{
py: 1,
borderLeft: selectedNode === node.id ? '3px solid' : 'none',
borderLeftColor: 'primary.main',
pl: selectedNode === node.id ? 1 : 2,
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{node.id === 'all' ? (
<ViewListIcon fontSize="small" color={selectedNode === node.id ? "primary" : "inherit"} />
) : (
<ComputerIcon fontSize="small" color={selectedNode === node.id ? "primary" : "inherit"} />
)}
</ListItemIcon>
<ListItemText
primary={node.name}
primaryTypographyProps={{
variant: 'body2',
fontWeight: selectedNode === node.id ? 600 : 400
}}
/>
{selectedNode === node.id && (
<CheckIcon
fontSize="small"
color="primary"
sx={{ ml: 1, fontSize: '1rem' }}
/>
)}
</MenuItem>
))}
</Box>
</Menu>
</>
);
};
export default NodeSelect;
-876
View File
@@ -1,876 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import {
Box,
InputBase,
IconButton,
Tooltip,
Chip,
alpha,
Paper,
Badge,
Popover,
Typography,
Divider
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import AddIcon from '@mui/icons-material/Add';
import { useSearchContext } from '../context/SearchContext';
import { useTheme } from '@mui/material/styles';
const SearchField = () => {
const theme = useTheme();
const {
searchTerm,
setSearchTerm,
activeSearchTerms,
addSearchTerm,
removeSearchTerm,
clearSearchTerms,
isSearching,
setIsSearching
} = useSearchContext();
const searchInputRef = useRef(null);
// State for filter bubble popover
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
const openFilters = Boolean(filterAnchorEl);
// State to track if we added a search prefix that needs cursor positioning
const [addedPrefix, setAddedPrefix] = useState(false);
// Position cursor after a prefix is added
useEffect(() => {
if (addedPrefix && searchInputRef.current) {
// Small delay to ensure input is updated
setTimeout(() => {
searchInputRef.current.focus();
searchInputRef.current.setSelectionRange(searchTerm.length, searchTerm.length);
setAddedPrefix(false);
}, 50);
}
}, [addedPrefix, searchTerm]);
// Handle opening filter bubble
const handleFilterClick = (event) => {
setFilterAnchorEl(event.currentTarget);
};
// Handle closing filter bubble
const handleFilterClose = () => {
setFilterAnchorEl(null);
};
// Focus the search input when isSearching becomes true
useEffect(() => {
if (isSearching && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isSearching]);
// Handle keyboard shortcuts in search field
useEffect(() => {
const handleKeyDown = (e) => {
// Only handle events when the search input is focused
if (e.target === searchInputRef.current) {
console.log('SearchField keydown event:', e.key, 'Current searchTerm:', searchTerm);
// Handle Escape key to clear all filters
if (e.key === 'Escape') {
e.preventDefault();
setSearchTerm('');
clearSearchTerms();
setIsSearching(false);
searchInputRef.current.blur();
}
// Handle Backspace key to remove last filter when input is empty
if (e.key === 'Backspace' && !searchTerm && activeSearchTerms.length > 0) {
e.preventDefault();
// Remove the last filter that was added
const lastTerm = activeSearchTerms[activeSearchTerms.length - 1];
removeSearchTerm(lastTerm);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [setSearchTerm, clearSearchTerms, setIsSearching, searchTerm, activeSearchTerms, removeSearchTerm]);
// Handle search submission
const handleSearchSubmit = (e) => {
e.preventDefault();
if (searchTerm.trim()) {
addSearchTerm(searchTerm.trim());
setSearchTerm('');
}
};
// Handle example filter click
const handleExampleClick = (filterTerm) => {
addSearchTerm(filterTerm);
setFilterAnchorEl(null); // Close the popover after adding
};
// Categorize search terms into different types
const categorizeSearchTerms = () => {
const categories = {
status: [],
type: [],
node: [],
role: [],
other: []
};
activeSearchTerms.forEach(term => {
if (term.startsWith('status:')) {
categories.status.push(term);
} else if (term.startsWith('type:')) {
categories.type.push(term);
} else if (term.startsWith('node:')) {
categories.node.push(term);
} else if (term.startsWith('role:')) {
categories.role.push(term);
} else {
categories.other.push(term);
}
});
return categories;
};
const filteredCategories = categorizeSearchTerms();
const hasActiveFilters = activeSearchTerms.length > 0;
// Custom chip style for example filters
const exampleChipStyle = {
fontSize: '0.75rem',
height: 24,
mr: 0.75,
mb: 0.75,
borderStyle: 'dashed',
borderWidth: '1px',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.3)}`
}
};
return (
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
{/* Search input */}
<Box
component="form"
onSubmit={handleSearchSubmit}
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: 2,
bgcolor: alpha(theme.palette.common.white, 0.15),
'&:hover': {
bgcolor: alpha(theme.palette.common.white, 0.25),
},
width: { xs: 180, sm: 250 },
}}
>
<Tooltip title="Search (/ or Ctrl+F)">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 1
}}
>
<SearchIcon sx={{ color: 'inherit', fontSize: '1.2rem' }} />
</Box>
</Tooltip>
<InputBase
placeholder="Search..."
value={searchTerm}
onChange={(e) => {
// Log the current input value
console.log('Search input onChange:', e.target.value, 'Previous value:', searchTerm);
// Directly set the search term to the current input value
// This should ensure the value is properly updated
setSearchTerm(e.target.value);
}}
onFocus={() => setIsSearching(true)}
onBlur={() => {
// Only set isSearching to false if there's no search term
if (!searchTerm) {
setIsSearching(false);
}
}}
inputRef={searchInputRef}
sx={{
color: 'inherit',
flex: 1,
'& .MuiInputBase-input': {
p: '4px 0',
width: '100%',
fontSize: '0.875rem',
},
}}
endAdornment={
searchTerm ? (
<IconButton
size="small"
aria-label="clear search"
onClick={() => {
setSearchTerm('');
// Focus the input after clearing
searchInputRef.current?.focus();
}}
sx={{
p: 0.5,
color: 'inherit',
opacity: 0.7,
'&:hover': {
opacity: 1,
}
}}
>
<ClearIcon fontSize="small" />
</IconButton>
) : null
}
/>
</Box>
{/* Filter bubble button - always shown */}
<Tooltip title="Filter options and tips">
<IconButton
aria-label="filter options"
onClick={handleFilterClick}
sx={{
ml: 0.5,
color: (openFilters || activeSearchTerms.length > 0) ? 'primary.main' : 'inherit',
opacity: openFilters ? 1 : 0.7,
'&:hover': {
opacity: 1,
}
}}
>
<Badge
badgeContent={activeSearchTerms.length}
color="primary"
sx={{
'& .MuiBadge-badge': {
fontSize: '0.65rem',
height: 16,
minWidth: 16,
padding: '0 4px'
}
}}
>
<FilterListIcon />
</Badge>
</IconButton>
</Tooltip>
{/* Filter popover with search terms */}
<Popover
open={openFilters}
anchorEl={filterAnchorEl}
onClose={handleFilterClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
mt: 0.5,
width: 320,
overflow: 'hidden'
}
}}
>
<Paper sx={{ maxHeight: 400, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{ p: 2, bgcolor: 'background.default', borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
{hasActiveFilters ? "Active Search Filters" : "Search Help"}
</Typography>
<Typography variant="caption" color="text.secondary">
{hasActiveFilters ? "These filters are currently applied to your search results." : "Click on any filter example below to apply it."}
</Typography>
</Box>
{/* Filters section - only show if there are active filters */}
{hasActiveFilters && (
<Box sx={{ p: 1.5, overflow: 'auto', flex: 1 }}>
{/* Status filters */}
{filteredCategories.status.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Status Filters:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{filteredCategories.status.map((term) => (
<Chip
key={term}
label={term}
size="small"
color="info"
onDelete={() => removeSearchTerm(term)}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
))}
</Box>
</Box>
)}
{/* Type filters */}
{filteredCategories.type.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
System Type Filters:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{filteredCategories.type.map((term) => (
<Chip
key={term}
label={term}
size="small"
color="success"
onDelete={() => removeSearchTerm(term)}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
))}
</Box>
</Box>
)}
{/* Node filters */}
{filteredCategories.node.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Node Filters:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{filteredCategories.node.map((term) => (
<Chip
key={term}
label={term}
size="small"
color="warning"
onDelete={() => removeSearchTerm(term)}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
))}
</Box>
</Box>
)}
{/* Role filters */}
{filteredCategories.role.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Role Filters:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{filteredCategories.role.map((term) => (
<Chip
key={term}
label={term}
size="small"
color="info"
onDelete={() => removeSearchTerm(term)}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
))}
</Box>
</Box>
)}
{/* Text search terms */}
{filteredCategories.other.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Text Search:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{filteredCategories.other.map((term) => (
<Chip
key={term}
label={term}
size="small"
color="default"
onDelete={() => removeSearchTerm(term)}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
))}
</Box>
</Box>
)}
{/* Clear all button */}
{activeSearchTerms.length > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Chip
label="Clear all filters"
size="small"
color="primary"
variant="outlined"
onClick={clearSearchTerms}
sx={{
fontSize: '0.75rem',
height: 24
}}
/>
</Box>
)}
</Box>
)}
{/* Search tips - always visible */}
{!hasActiveFilters ? (
<Box sx={{ p: 1.5, overflow: 'auto', flex: 1 }}>
<Typography variant="caption" color="text.secondary" component="div" sx={{ mb: 2 }}>
Use filters to quickly find the exact systems you're looking for. Combine multiple filters for more specific results.
</Typography>
{/* Filter by status examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Filter by system status:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="status:running"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("status:running")}
sx={exampleChipStyle}
/>
<Chip
label="status:stopped"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("status:stopped")}
sx={exampleChipStyle}
/>
<Chip
label="status:paused"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("status:paused")}
sx={exampleChipStyle}
/>
<Chip
label="status:suspended"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("status:suspended")}
sx={exampleChipStyle}
/>
</Box>
</Box>
{/* Filter by type examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Filter by system type:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="type:vm"
size="small"
variant="outlined"
color="success"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("type:vm")}
sx={exampleChipStyle}
/>
<Chip
label="type:ct"
size="small"
variant="outlined"
color="success"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("type:ct")}
sx={exampleChipStyle}
/>
<Chip
label="type:qemu"
size="small"
variant="outlined"
color="success"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("type:qemu")}
sx={exampleChipStyle}
/>
<Chip
label="type:lxc"
size="small"
variant="outlined"
color="success"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("type:lxc")}
sx={exampleChipStyle}
/>
</Box>
</Box>
{/* Filter by node examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Filter by node location:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="node:"
size="small"
variant="outlined"
color="warning"
icon={<AddIcon fontSize="small" />}
onClick={() => {
setSearchTerm("node:");
setAddedPrefix(true);
setFilterAnchorEl(null);
}}
sx={exampleChipStyle}
/>
</Box>
</Box>
{/* Filter by role examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Filter by HA status:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="role:"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => {
setSearchTerm("role:");
setAddedPrefix(true);
setFilterAnchorEl(null);
}}
sx={exampleChipStyle}
/>
<Chip
label="role:primary"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:primary")}
sx={exampleChipStyle}
/>
<Chip
label="role:secondary"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:secondary")}
sx={exampleChipStyle}
/>
<Chip
label="role:migrating"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:migrating")}
sx={exampleChipStyle}
/>
<Chip
label="role:error"
size="small"
variant="outlined"
color="error"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:error")}
sx={exampleChipStyle}
/>
<Chip
label="role:fence"
size="small"
variant="outlined"
color="warning"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:fence")}
sx={exampleChipStyle}
/>
<Chip
label="role:recovery"
size="small"
variant="outlined"
color="warning"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:recovery")}
sx={exampleChipStyle}
/>
<Chip
label="role:-"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role:-")}
sx={exampleChipStyle}
/>
<Chip
label="role"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("role")}
sx={{
...exampleChipStyle,
border: `1px dashed ${theme.palette.info.main}`,
}}
/>
</Box>
</Box>
{/* Filter by resources examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Filter by resource usage:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="cpu>50"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("cpu>50")}
sx={exampleChipStyle}
/>
<Chip
label="cpu<30"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("cpu<30")}
sx={exampleChipStyle}
/>
<Chip
label="cpu=50"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("cpu=50")}
sx={exampleChipStyle}
/>
<Chip
label="memory>80"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("memory>80")}
sx={exampleChipStyle}
/>
<Chip
label="memory<=25"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("memory<=25")}
sx={exampleChipStyle}
/>
<Chip
label="disk>=90"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("disk>=90")}
sx={exampleChipStyle}
/>
<Chip
label="network>100"
size="small"
variant="outlined"
color="info"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("network>100")}
sx={exampleChipStyle}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block', width: '100%' }}>
<em>Supports {'>'}{'<'} {'>='} {'<='} = operators</em>
</Typography>
</Box>
</Box>
{/* OR search examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
OR search (matches either term):
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="web database"
size="small"
variant="outlined"
color="default"
onClick={() => handleExampleClick("web database")}
sx={exampleChipStyle}
/>
<Chip
label="vm|ct"
size="small"
variant="outlined"
color="default"
onClick={() => handleExampleClick("vm|ct")}
sx={exampleChipStyle}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block', width: '100%' }}>
<em>Use spaces or | for OR logic</em>
</Typography>
</Box>
</Box>
{/* Text search examples */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Free text search:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="web"
size="small"
variant="outlined"
color="default"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("web")}
sx={exampleChipStyle}
/>
<Chip
label="database"
size="small"
variant="outlined"
color="default"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("database")}
sx={exampleChipStyle}
/>
<Chip
label="nginx"
size="small"
variant="outlined"
color="default"
icon={<AddIcon fontSize="small" />}
onClick={() => handleExampleClick("nginx")}
sx={exampleChipStyle}
/>
</Box>
</Box>
{/* Common combined searches */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Common combined searches:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label="status:running type:vm"
size="small"
variant="outlined"
color="primary"
onClick={() => {
addSearchTerm("status:running");
addSearchTerm("type:vm");
setFilterAnchorEl(null);
}}
sx={exampleChipStyle}
/>
<Chip
label="role:primary cpu>50"
size="small"
variant="outlined"
color="primary"
onClick={() => {
addSearchTerm("role:primary");
addSearchTerm("cpu>50");
setFilterAnchorEl(null);
}}
sx={exampleChipStyle}
/>
</Box>
</Box>
<Box sx={{ mt: 2, pt: 1, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
Tip: Each filter adds AND logic. Add multiple filters for precise results.
</Typography>
</Box>
</Box>
) : (
<Divider />
)}
{/* Power user tip at the bottom */}
<Box sx={{ p: 1.5, bgcolor: alpha(theme.palette.primary.main, 0.05) }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InfoOutlinedIcon color="primary" fontSize="small" sx={{ mr: 0.5 }} />
<Typography variant="caption" fontWeight="medium" color="primary.main">
Keyboard Shortcuts
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" component="div" sx={{ mt: 0.5 }}>
Press <strong>/</strong> or <strong>Ctrl+F</strong> from any screen to focus search<br />
<strong>Backspace</strong> removes the last filter when input is empty<br />
<strong>ESC</strong> clears all active filters and closes search<br />
<strong>Enter</strong> adds your current search term as a filter
</Typography>
</Box>
</Paper>
</Popover>
</Box>
);
};
export default SearchField;
@@ -1,134 +0,0 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
IconButton,
useTheme,
alpha
} from '@mui/material';
import CancelIcon from '@mui/icons-material/Cancel';
import RefreshIcon from '@mui/icons-material/Refresh';
import { fadeIn } from '../../constants/networkConstants';
import { useThemeContext } from '../../context/ThemeContext';
const ConnectionErrorDisplay = ({ connectionStatus, error, onReconnect }) => {
const { isDarkMode } = useThemeContext();
const theme = useTheme();
let title, message, icon, severity;
switch (connectionStatus) {
case 'error':
title = 'Connection Error';
message = error || 'Unable to connect to the server. The server may be offline or unreachable.';
icon = <CancelIcon sx={{ mr: 1 }} />;
severity = 'error';
break;
case 'disconnected':
title = 'Server Disconnected';
message = 'The connection to the server has been lost. This may happen if the server was stopped or restarted.';
icon = <CancelIcon sx={{ mr: 1 }} />;
severity = 'warning';
break;
default:
title = 'Connection Issue';
message = 'There is an issue with the connection to the server.';
icon = <CancelIcon sx={{ mr: 1 }} />;
severity = 'error';
}
// Use theme directly to ensure proper dark mode detection
const mode = theme.palette.mode;
const isDark = mode === 'dark';
// Define colors based on theme mode and severity
const bgColor = isDark
? (severity === 'error' ? 'rgba(244, 67, 54, 0.15)' : 'rgba(255, 193, 7, 0.15)')
: (severity === 'error' ? '#FFF4F4' : '#FFF8E1');
const borderColor = isDark
? (severity === 'error' ? 'rgba(244, 67, 54, 0.3)' : 'rgba(255, 193, 7, 0.3)')
: (severity === 'error' ? '#ffcdd2' : '#ffe082');
const textColor = isDark ? theme.palette.text.primary : 'rgba(0, 0, 0, 0.87)';
const secondaryTextColor = isDark ? theme.palette.text.secondary : 'rgba(0, 0, 0, 0.6)';
return (
<Card
sx={{
mb: 2,
bgcolor: bgColor,
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${borderColor}`,
animation: `${fadeIn} 0.3s ease-out`,
// Force dark mode styles
...(isDark && {
backgroundColor: severity === 'error' ? 'rgba(244, 67, 54, 0.15)' : 'rgba(255, 193, 7, 0.15)',
borderColor: severity === 'error' ? 'rgba(244, 67, 54, 0.3)' : 'rgba(255, 193, 7, 0.3)',
color: theme.palette.text.primary
})
}}
>
<CardContent sx={{ ...(isDark && { color: theme.palette.text.primary }) }}>
<Typography
color={severity}
variant="h6"
sx={{ display: 'flex', alignItems: 'center', mb: 1 }}
>
{icon}
{title}
</Typography>
<Typography sx={{ mb: 2, color: textColor }}>{message}</Typography>
<Typography variant="body2" sx={{ mb: 2, color: secondaryTextColor }}>
Please check that:
<ul>
<li>The server application is running</li>
<li>Your network connection is working</li>
<li>Any firewalls or security software are not blocking the connection</li>
<li>The Socket.io server is properly configured and running on port 7654</li>
</ul>
{error && error.includes('Socket.io') && (
<Box sx={{ mt: 1, p: 1, bgcolor: alpha(theme.palette.error.main, 0.1), borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: theme.palette.error.main }}>
Socket.io Connection Issue Detected
</Typography>
<Typography variant="body2">
The application is having trouble connecting to the Socket.io server. This is required for real-time updates.
Try restarting the server with <code>npm run dev</code>.
</Typography>
</Box>
)}
</Typography>
{onReconnect && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<IconButton
onClick={onReconnect}
color="primary"
sx={{
border: '1px solid',
borderColor: 'primary.main',
borderRadius: 1,
px: 2,
py: 0.5,
fontSize: '0.875rem',
'& .MuiSvgIcon-root': { mr: 1 },
...(isDark && {
color: theme.palette.primary.main,
borderColor: theme.palette.primary.main
})
}}
>
<RefreshIcon fontSize="small" />
Try Reconnecting
</IconButton>
</Box>
)}
</CardContent>
</Card>
);
};
export default ConnectionErrorDisplay;
@@ -1,72 +0,0 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TableCell, TableSortLabel, Tooltip } from '@mui/material';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
const DraggableColumnHeader = ({
id,
label,
width,
minWidth,
sortConfig,
requestSort,
isDragging,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
width: width || 'auto',
minWidth: minWidth || 'auto',
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? 'rgba(0, 0, 0, 0.05)' : 'inherit',
};
// Helper function to get sort direction
const getSortDirection = (key) => {
if (!sortConfig || !key) return 'asc';
return sortConfig.key === key ? sortConfig.direction : 'asc';
};
return (
<TableCell
ref={setNodeRef}
style={style}
{...attributes}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title="Drag to reorder column">
<span
style={{
display: 'inline-flex',
alignItems: 'center',
marginRight: '8px',
cursor: 'grab',
color: '#1976d2'
}}
{...listeners}
>
<DragIndicatorIcon fontSize="small" color="primary" />
</span>
</Tooltip>
<TableSortLabel
active={sortConfig?.key === id}
direction={getSortDirection(id)}
onClick={() => requestSort(id)}
>
{label || id}
</TableSortLabel>
</div>
</TableCell>
);
};
export default DraggableColumnHeader;
@@ -1,277 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Tooltip
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RefreshIcon from '@mui/icons-material/Refresh';
import BugReportIcon from '@mui/icons-material/BugReport';
import { formatBytes, formatPercentage } from '../../utils/formatters';
import { detectCpuValueFormat, normalizeCpuValue } from '../../utils/metricsDebugger';
/**
* Component for debugging metrics data
*/
const MetricsDebugPanel = ({ rawMetrics, formattedMetrics }) => {
const [expanded, setExpanded] = useState(false);
const [refreshCounter, setRefreshCounter] = useState(0);
// Force refresh
const handleRefresh = () => {
setRefreshCounter(prev => prev + 1);
};
// Sample metrics for testing
const [sampleMetrics, setSampleMetrics] = useState([
{ type: 'Decimal CPU (0-1)', value: 0.05, normalized: normalizeCpuValue(0.05) },
{ type: 'Percentage CPU (0-100)', value: 25, normalized: normalizeCpuValue(25) },
{ type: 'High CPU (>100)', value: 150, normalized: normalizeCpuValue(150) },
{ type: 'Zero CPU', value: 0, normalized: normalizeCpuValue(0) },
]);
// Update sample metrics when refresh is triggered
useEffect(() => {
setSampleMetrics([
{ type: 'Decimal CPU (0-1)', value: Math.random(), normalized: normalizeCpuValue(Math.random()) },
{ type: 'Percentage CPU (0-100)', value: Math.random() * 100, normalized: normalizeCpuValue(Math.random() * 100) },
{ type: 'High CPU (>100)', value: 100 + Math.random() * 100, normalized: normalizeCpuValue(100 + Math.random() * 100) },
{ type: 'Zero CPU', value: 0, normalized: normalizeCpuValue(0) },
]);
}, [refreshCounter]);
// Extract metrics data for display
const metricsData = React.useMemo(() => {
if (!rawMetrics || !Array.isArray(rawMetrics) || rawMetrics.length === 0) {
return [];
}
return rawMetrics.map(metric => {
if (!metric || !metric.guestId) return null;
const cpuValue = typeof metric.cpu === 'number' ? metric.cpu : null;
const cpuFormat = cpuValue !== null ? detectCpuValueFormat(cpuValue) : 'unknown';
const normalizedCpu = cpuValue !== null ? normalizeCpuValue(cpuValue) : null;
return {
guestId: metric.guestId,
cpu: cpuValue,
cpuFormat,
normalizedCpu,
memory: typeof metric.memory === 'number' ? metric.memory : null,
memoryTotal: typeof metric.memoryTotal === 'number' ? metric.memoryTotal : null,
memoryPercentage: (metric.memory && metric.memoryTotal) ? (metric.memory / metric.memoryTotal) * 100 : null,
netIn: typeof metric.netIn === 'number' ? metric.netIn : null,
netOut: typeof metric.netOut === 'number' ? metric.netOut : null,
};
}).filter(Boolean);
}, [rawMetrics, refreshCounter]);
// Extract formatted metrics data for display
const formattedMetricsData = React.useMemo(() => {
if (!formattedMetrics || !formattedMetrics.cpu) {
return [];
}
const guestIds = Object.keys(formattedMetrics.cpu);
return guestIds.map(guestId => {
const cpu = formattedMetrics.cpu[guestId];
const memory = formattedMetrics.memory[guestId];
const disk = formattedMetrics.disk[guestId];
const network = formattedMetrics.network[guestId];
return {
guestId,
cpuUsage: cpu?.usage ?? null,
memoryUsed: memory?.used ?? null,
memoryTotal: memory?.total ?? null,
memoryPercentage: memory?.usagePercent ?? null,
diskUsed: disk?.used ?? null,
diskTotal: disk?.total ?? null,
diskPercentage: disk?.usagePercent ?? null,
netIn: network?.inRate ?? null,
netOut: network?.outRate ?? null,
};
});
}, [formattedMetrics, refreshCounter]);
return (
<Accordion
expanded={expanded}
onChange={() => setExpanded(!expanded)}
sx={{
mt: 2,
mb: 2,
border: '1px dashed',
borderColor: 'warning.main',
'&:before': { display: 'none' }
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
bgcolor: 'warning.light',
color: 'warning.contrastText',
'& .MuiAccordionSummary-content': {
alignItems: 'center'
}
}}
>
<BugReportIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">
Metrics Debug Panel
</Typography>
<Tooltip title="Refresh data">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRefresh();
}}
sx={{ ml: 2 }}
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
CPU Value Normalization Test
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>Raw Value</TableCell>
<TableCell>Format</TableCell>
<TableCell>Normalized (0-100%)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sampleMetrics.map((sample, index) => (
<TableRow key={index}>
<TableCell>{sample.type}</TableCell>
<TableCell>{sample.value.toFixed(4)}</TableCell>
<TableCell>{detectCpuValueFormat(sample.value)}</TableCell>
<TableCell>{sample.normalized.toFixed(2)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
{metricsData.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Raw Metrics Data ({metricsData.length} guests)
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Guest ID</TableCell>
<TableCell>CPU Value</TableCell>
<TableCell>CPU Format</TableCell>
<TableCell>Normalized CPU</TableCell>
<TableCell>Memory</TableCell>
<TableCell>Network In</TableCell>
<TableCell>Network Out</TableCell>
</TableRow>
</TableHead>
<TableBody>
{metricsData.slice(0, 5).map((metric, index) => (
<TableRow key={index}>
<TableCell>{metric.guestId}</TableCell>
<TableCell>{metric.cpu !== null ? metric.cpu.toFixed(4) : 'N/A'}</TableCell>
<TableCell>{metric.cpuFormat}</TableCell>
<TableCell>{metric.normalizedCpu !== null ? `${metric.normalizedCpu.toFixed(2)}%` : 'N/A'}</TableCell>
<TableCell>
{metric.memory !== null && metric.memoryTotal !== null
? `${formatBytes(metric.memory)} / ${formatBytes(metric.memoryTotal)} (${formatPercentage(metric.memoryPercentage)})`
: 'N/A'
}
</TableCell>
<TableCell>{metric.netIn !== null ? formatBytes(metric.netIn) + '/s' : 'N/A'}</TableCell>
<TableCell>{metric.netOut !== null ? formatBytes(metric.netOut) + '/s' : 'N/A'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{metricsData.length > 5 && (
<Typography variant="caption" color="text.secondary">
Showing 5 of {metricsData.length} guests
</Typography>
)}
</Box>
)}
{formattedMetricsData.length > 0 && (
<Box>
<Typography variant="h6" gutterBottom>
Formatted Metrics Data ({formattedMetricsData.length} guests)
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Guest ID</TableCell>
<TableCell>CPU Usage</TableCell>
<TableCell>Memory</TableCell>
<TableCell>Disk</TableCell>
<TableCell>Network In</TableCell>
<TableCell>Network Out</TableCell>
</TableRow>
</TableHead>
<TableBody>
{formattedMetricsData.slice(0, 5).map((metric, index) => (
<TableRow key={index}>
<TableCell>{metric.guestId}</TableCell>
<TableCell>{metric.cpuUsage !== null ? `${metric.cpuUsage.toFixed(2)}%` : 'N/A'}</TableCell>
<TableCell>
{metric.memoryUsed !== null && metric.memoryTotal !== null
? `${formatBytes(metric.memoryUsed)} / ${formatBytes(metric.memoryTotal)} (${formatPercentage(metric.memoryPercentage)})`
: 'N/A'
}
</TableCell>
<TableCell>
{metric.diskUsed !== null && metric.diskTotal !== null
? `${formatBytes(metric.diskUsed)} / ${formatBytes(metric.diskTotal)} (${formatPercentage(metric.diskPercentage)})`
: 'N/A'
}
</TableCell>
<TableCell>{metric.netIn !== null ? formatBytes(metric.netIn) + '/s' : 'N/A'}</TableCell>
<TableCell>{metric.netOut !== null ? formatBytes(metric.netOut) + '/s' : 'N/A'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{formattedMetricsData.length > 5 && (
<Typography variant="caption" color="text.secondary">
Showing 5 of {formattedMetricsData.length} guests
</Typography>
)}
</Box>
)}
</AccordionDetails>
</Accordion>
);
};
export default MetricsDebugPanel;
File diff suppressed because it is too large Load Diff
@@ -1,460 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Chip,
Slider,
FormControlLabel,
Switch,
IconButton,
Collapse,
Paper,
InputBase,
Tooltip,
Badge,
alpha,
Select,
MenuItem,
FormControl,
InputLabel,
ToggleButtonGroup,
ToggleButton,
Divider,
Button,
useTheme
} from '@mui/material';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import ComputerIcon from '@mui/icons-material/Computer';
import DnsIcon from '@mui/icons-material/Dns';
import ViewListIcon from '@mui/icons-material/ViewList';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { formatPercentage, formatNetworkRateForFilter, sliderValueToNetworkRate } from '../../utils/formatters';
import { KeyboardShortcut } from './UIComponents';
import { useSearchContext } from '../../context/SearchContext';
const NetworkFilters = ({
filters,
updateFilter,
clearFilter,
resetFilters,
showStopped,
setShowStopped,
searchTerm,
setSearchTerm,
activeSearchTerms,
addSearchTerm,
removeSearchTerm,
activeFilterCount,
showFilters,
handleSliderDragStart,
handleSliderDragEnd,
searchInputRef,
guestTypeFilter,
setGuestTypeFilter,
handleClose,
formatPercentage,
formatNetworkRateForFilter
}) => {
const theme = useTheme();
// Get clearSearchTerms from context
const { clearSearchTerms } = useSearchContext();
// Handle resetting all filters including search terms
const handleResetAllFilters = () => {
resetFilters();
clearSearchTerms();
if (handleClose) handleClose();
};
// Handle search input submission
const handleSearchSubmit = (e) => {
e.preventDefault();
if (searchTerm.trim()) {
// Add the entire search term (which can contain multiple space-separated words)
// This creates an AND search as the filtering logic uses .every()
addSearchTerm(searchTerm.trim());
setSearchTerm('');
}
};
// Handle guest type filter change
const handleGuestTypeChange = (event, newValue) => {
if (newValue !== null) {
setGuestTypeFilter(newValue);
}
};
return (
<Paper
elevation={0}
sx={{
width: 350,
maxWidth: '100vw',
maxHeight: '80vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
{/* Header */}
<Box sx={{ px: 2, py: 1, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>
Filter Systems
</Typography>
<Typography variant="caption" color="text.secondary">
Filter by properties or search for specific systems.
</Typography>
</Box>
<Divider />
{/* Search input */}
<Box
component="form"
onSubmit={handleSearchSubmit}
sx={{
display: 'flex',
alignItems: 'center',
px: 2,
py: 1,
bgcolor: 'background.paper',
}}
>
<SearchIcon sx={{ color: 'text.secondary', mr: 1 }} />
<InputBase
placeholder="Press Enter to search..."
value={searchTerm}
onChange={(e) => {
// Log the current input value for debugging
console.log('NetworkFilters input onChange:', e.target.value, 'Previous value:', searchTerm);
// Directly set the search term to the current input value
// This should ensure the value is properly updated
setSearchTerm(e.target.value);
}}
sx={{ flex: 1 }}
inputRef={searchInputRef}
inputProps={{ 'aria-label': 'search systems' }}
endAdornment={
searchTerm ? (
<IconButton
size="small"
aria-label="clear search"
onClick={() => {
setSearchTerm('');
searchInputRef.current?.focus();
}}
sx={{ p: 0.5 }}
>
<ClearIcon fontSize="small" />
</IconButton>
) : null
}
/>
</Box>
{/* Search Pro Tips */}
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" color="text.secondary">
<strong>Tips:</strong> Use <strong>status:running</strong> or <strong>status:stopped</strong> to filter by status. Use <strong>type:vm</strong> or <strong>type:ct</strong> for system types. Use <strong>cpu{'>'}50</strong> to filter by CPU usage, <strong>memory{'>'}80</strong> or <strong>disk{'>'}90</strong> to filter by resource usage. Use <strong>role</strong> to show all HA-managed guests, <strong>role:primary</strong> for primary nodes, <strong>role:secondary</strong> for secondary nodes, <strong>role:migrating</strong> for guests being migrated, <strong>role:error</strong> for guests in error state.
</Typography>
</Box>
{/* Active search terms */}
{activeSearchTerms.length > 0 && (
<Box sx={{ px: 2, py: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{activeSearchTerms.map((term) => (
<Chip
key={term}
label={term}
size="small"
onDelete={() => removeSearchTerm(term)}
/>
))}
</Box>
)}
<Divider />
{/* Guest type filter */}
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="body2" gutterBottom>
System Type
</Typography>
<ToggleButtonGroup
value={guestTypeFilter}
exclusive
onChange={handleGuestTypeChange}
aria-label="system type filter"
size="small"
sx={{
width: '100%',
'& .MuiToggleButton-root': {
borderRadius: 1.5,
py: 1,
textTransform: 'none',
fontWeight: 500,
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.dark',
},
'& .MuiSvgIcon-root': {
color: 'primary.contrastText'
}
},
'&:hover': {
backgroundColor: 'action.hover'
}
},
'& .MuiToggleButtonGroup-grouped': {
mx: 0.5,
border: '1px solid',
borderColor: 'divider',
'&:not(:first-of-type)': {
borderLeft: '1px solid',
borderLeftColor: 'divider',
},
'&:first-of-type': {
ml: 0
},
'&:last-of-type': {
mr: 0
}
}
}}
>
<ToggleButton value="all" aria-label="all systems" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ViewListIcon fontSize="small" sx={{ mr: 0.5 }} />
All
</Box>
</ToggleButton>
<ToggleButton value="vm" aria-label="virtual machines only" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ComputerIcon fontSize="small" sx={{ mr: 0.5 }} />
VMs
</Box>
</ToggleButton>
<ToggleButton value="ct" aria-label="containers only" sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<DnsIcon fontSize="small" sx={{ mr: 0.5 }} />
CTs
</Box>
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Divider />
{/* Sliders for numeric filters */}
<Box sx={{ px: 2, py: 1, maxHeight: 300, overflow: 'auto' }}>
<Typography variant="body2" gutterBottom>
Resource Filters
</Typography>
{/* CPU Filter */}
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.cpu > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color={filters.cpu > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}>
CPU Usage
</Typography>
<Typography variant="caption" color={filters.cpu > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.cpu > 0 ? 500 : 400 }}>
{formatPercentage(filters.cpu)}+
</Typography>
</Box>
<Slider
id="cpu-filter-slider"
value={filters.cpu}
onChange={(e, newValue) => updateFilter('cpu', newValue)}
onMouseDown={() => handleSliderDragStart('cpu')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="cpu-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
min={0}
max={100}
size="small"
sx={{
color: filters.cpu > 0 ? theme.palette.primary.main : undefined,
'& .MuiSlider-thumb': {
boxShadow: filters.cpu > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined,
}
}}
/>
</Box>
{/* Memory Filter */}
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.memory > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color={filters.memory > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}>
Memory Usage
</Typography>
<Typography variant="caption" color={filters.memory > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.memory > 0 ? 500 : 400 }}>
{formatPercentage(filters.memory)}+
</Typography>
</Box>
<Slider
id="memory-filter-slider"
value={filters.memory}
onChange={(e, newValue) => updateFilter('memory', newValue)}
onMouseDown={() => handleSliderDragStart('memory')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="memory-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
min={0}
max={100}
size="small"
sx={{
color: filters.memory > 0 ? theme.palette.primary.main : undefined,
'& .MuiSlider-thumb': {
boxShadow: filters.memory > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined,
}
}}
/>
</Box>
{/* Disk Filter */}
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.disk > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color={filters.disk > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}>
Disk Usage
</Typography>
<Typography variant="caption" color={filters.disk > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.disk > 0 ? 500 : 400 }}>
{formatPercentage(filters.disk)}+
</Typography>
</Box>
<Slider
id="disk-filter-slider"
value={filters.disk}
onChange={(e, newValue) => updateFilter('disk', newValue)}
onMouseDown={() => handleSliderDragStart('disk')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="disk-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
min={0}
max={100}
size="small"
sx={{
color: filters.disk > 0 ? theme.palette.primary.main : undefined,
'& .MuiSlider-thumb': {
boxShadow: filters.disk > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined,
}
}}
/>
</Box>
{/* Download Filter */}
<Box sx={{
mb: 2,
p: 1,
borderRadius: 1,
bgcolor: filters.download > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color={filters.download > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}>
Download Rate
</Typography>
<Typography variant="caption" color={filters.download > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.download > 0 ? 500 : 400 }}>
{formatNetworkRateForFilter(filters.download)}+
</Typography>
</Box>
<Slider
id="download-filter-slider"
value={filters.download}
onChange={(e, newValue) => updateFilter('download', newValue)}
onMouseDown={() => handleSliderDragStart('download')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="download-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
min={0}
max={100000}
size="small"
sx={{
color: filters.download > 0 ? theme.palette.primary.main : undefined,
'& .MuiSlider-thumb': {
boxShadow: filters.download > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined,
}
}}
/>
</Box>
{/* Upload Filter */}
<Box sx={{
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: filters.upload > 0 ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" color={filters.upload > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}>
Upload Rate
</Typography>
<Typography variant="caption" color={filters.upload > 0 ? "primary" : "text.secondary"} sx={{ fontWeight: filters.upload > 0 ? 500 : 400 }}>
{formatNetworkRateForFilter(filters.upload)}+
</Typography>
</Box>
<Slider
id="upload-filter-slider"
value={filters.upload}
onChange={(e, newValue) => updateFilter('upload', newValue)}
onMouseDown={() => handleSliderDragStart('upload')}
onMouseUp={handleSliderDragEnd}
aria-labelledby="upload-filter-slider"
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
min={0}
max={100000}
size="small"
sx={{
color: filters.upload > 0 ? theme.palette.primary.main : undefined,
'& .MuiSlider-thumb': {
boxShadow: filters.upload > 0 ? `0 0 0 8px ${alpha(theme.palette.primary.main, 0.16)}` : undefined,
}
}}
/>
</Box>
</Box>
<Divider />
{/* Reset button */}
<Box sx={{ px: 2, py: 1 }}>
<Button
size="small"
fullWidth
variant="outlined"
onClick={handleResetAllFilters}
startIcon={<FilterAltOffIcon />}
>
Reset All Filters
</Button>
</Box>
</Paper>
);
};
export default NetworkFilters;
@@ -1,220 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import {
TableBody,
TableRow,
TableCell,
Box,
Typography,
Chip,
Button,
Divider,
alpha,
useTheme
} from '@mui/material';
import NetworkCheckIcon from '@mui/icons-material/NetworkCheck';
import FilterAltIcon from '@mui/icons-material/FilterList';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import NetworkTableRow from './NetworkTableRow';
import { getNodeTextColor } from '../../utils/colorUtils';
const NetworkTableBody = ({
sortedAndFilteredData,
guestData,
metricsData,
columnVisibility,
getNodeName,
extractNumericId,
resetFilters,
showStopped,
setShowStopped,
guestTypeFilter,
resetColumnVisibility,
forceUpdateCounter,
columnOrder,
activeFilteredColumns = {},
thresholdColumn,
thresholdValue,
sharedGuestIdMap = {},
filters = {}
}) => {
const theme = useTheme();
// Check if any columns are visible
const hasVisibleColumns = Object.values(columnVisibility).some(col => col.visible);
// Force re-render when forceUpdateCounter changes
useEffect(() => {
if (forceUpdateCounter > 0) {
console.log('NetworkTableBody re-rendering due to forceUpdateCounter:', forceUpdateCounter);
}
}, [forceUpdateCounter]);
// Group guests by node
const groupedGuests = useMemo(() => {
if (!sortedAndFilteredData || !Array.isArray(sortedAndFilteredData)) {
return [];
}
// Use a Map to preserve insertion order
const nodeGroups = new Map();
// Group guests by node
sortedAndFilteredData.forEach(guest => {
const nodeName = getNodeName(guest?.node) || 'Unknown';
if (!nodeGroups.has(nodeName)) {
nodeGroups.set(nodeName, []);
}
nodeGroups.get(nodeName).push(guest);
});
// Convert Map to array of objects
return Array.from(nodeGroups.entries()).map(([nodeName, guests]) => ({
nodeName,
guests
}));
}, [sortedAndFilteredData, getNodeName]);
// If no columns are visible, show a message
if (!hasVisibleColumns) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 2 }}>
<VisibilityOffIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
No columns are visible
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Use the column visibility button to show some columns
</Typography>
<Button
variant="outlined"
onClick={resetColumnVisibility}
startIcon={<VisibilityOffIcon />}
>
Reset Columns
</Button>
</Box>
</TableCell>
</TableRow>
</TableBody>
);
}
// If no data matches the filters, show a message
if (sortedAndFilteredData.length === 0) {
return (
<TableBody sx={{ '& tr:last-child td': { borderBottom: 0 } }}>
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 2 }}>
<FilterAltIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
No matching guests found
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Try adjusting your filters or search terms
</Typography>
<Button
variant="outlined"
onClick={resetFilters}
startIcon={<FilterAltIcon />}
>
Reset Filters
</Button>
</Box>
</TableCell>
</TableRow>
</TableBody>
);
}
// Calculate visible columns for node header colspan
const visibleColumnCount = Object.values(columnVisibility).filter(col => col.visible).length;
// Render the table with data grouped by node
return (
<TableBody sx={{
'& tr:last-child td': { borderBottom: 0 },
'& .MuiTableRow-root': { borderBottom: '1px solid', borderColor: 'divider' }
}}>
{groupedGuests.map((nodeGroup, groupIndex) => {
const { nodeName, guests } = nodeGroup;
return (
<React.Fragment key={`node-${nodeName}`}>
{/* Node Header/Divider */}
<TableRow
sx={{
bgcolor: theme => alpha(theme.palette.primary.main, 0.05),
'&:hover': {
bgcolor: theme => alpha(theme.palette.primary.main, 0.08)
}
}}
>
<TableCell
colSpan={visibleColumnCount}
sx={{
py: 0.75,
px: 2,
borderBottom: '2px solid',
borderTop: groupIndex > 0 ? '2px solid' : 'none',
borderColor: theme => alpha(theme.palette.divider, 0.8)
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
color: theme => {
const color = getNodeTextColor(nodeName, theme.palette.mode);
return color;
}
}}
>
NODE: {nodeName}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 500 }}
>
{guests.length} {guests.length === 1 ? 'guest' : 'guests'}
</Typography>
</Box>
</TableCell>
</TableRow>
{/* Guest Rows */}
{guests.map(guest => (
<NetworkTableRow
key={guest.id}
guest={guest}
metrics={metricsData}
columnVisibility={columnVisibility}
getNodeName={getNodeName}
extractNumericId={extractNumericId}
columnOrder={columnOrder}
activeFilteredColumns={activeFilteredColumns}
thresholdColumn={thresholdColumn}
thresholdValue={thresholdValue}
sharedGuestIdMap={sharedGuestIdMap}
filters={filters}
/>
))}
</React.Fragment>
);
})}
</TableBody>
);
};
export default NetworkTableBody;
File diff suppressed because it is too large Load Diff
@@ -1,801 +0,0 @@
import React, { useMemo } from 'react';
import {
TableRow,
TableCell,
Typography,
Box,
Chip,
Tooltip,
alpha,
useTheme
} from '@mui/material';
import { StatusIndicator, ProgressWithLabel } from './UIComponents';
import { formatBytes, formatNetworkRate, formatUptime, formatPercentage, formatBytesWithUnit } from '../../utils/formatters';
import { calculateDynamicColumnWidths } from '../../utils/networkUtils';
import { useSearchContext } from '../../context/SearchContext';
import { useUserSettings } from '../../context/UserSettingsContext';
import { getNodeColor, getNodeTextColor } from '../../utils/colorUtils';
const NetworkTableRow = ({
guest,
metrics,
columnVisibility,
getNodeName,
extractNumericId,
columnOrder,
activeFilteredColumns = {},
thresholdColumn,
thresholdValue,
sharedGuestIds = {},
filters
}) => {
const theme = useTheme();
// DEBUG: Log the guest data to see what hastate values are coming in
// More detailed logging for the first few guests to keep logs manageable
const guestIdNum = parseInt(guest?.id?.toString() || '0', 10);
if (guestIdNum <= 5) {
console.log('Guest details:', {
id: guest.id,
name: guest.name,
node: guest.node,
status: guest.status,
hastate: guest.hastate,
shared: guest.shared,
isRunning: guest.status?.toLowerCase() === 'running'
});
}
// Get search terms for highlighting
const { searchTerm, activeSearchTerms } = useSearchContext();
// Get user settings for compact mode
const { getTableCellPadding } = useUserSettings();
// Get the node name for this guest
const nodeName = getNodeName(guest?.node);
// Generate a text color for the node name
const nodeTextColor = useMemo(() => {
return getNodeTextColor(nodeName, theme.palette.mode);
}, [nodeName, theme.palette.mode]);
// Combine all active search terms for highlighting
const allSearchTerms = useMemo(() => {
const terms = [...activeSearchTerms];
if (searchTerm && !terms.includes(searchTerm)) {
terms.push(searchTerm);
}
// Process compound terms (e.g., with spaces or |)
const processedTerms = [];
// Keep track of column-specific search terms and their associated values
const columnSearchTerms = {};
terms.forEach(term => {
const termLower = term.toLowerCase().trim();
// Skip empty terms
if (!termLower) return;
// Handle prefixed terms with colon (e.g., "node:pve-01")
if (termLower.includes(':')) {
const [prefix, value] = termLower.split(':', 2);
const prefixTrim = prefix.trim();
// Add the original term
processedTerms.push({
term: termLower,
isColumnSpecific: true,
columnId: prefixTrim
});
// Store the value for this column for highlighting only in that column
if (value && value.trim() !== '') {
if (!columnSearchTerms[prefixTrim]) {
columnSearchTerms[prefixTrim] = [];
}
columnSearchTerms[prefixTrim].push(value.trim());
}
} else if (termLower.split(' ').length > 1) {
// Handle OR search with spaces
const spaceTerms = termLower.split(' ').map(t => t.trim()).filter(t => t);
spaceTerms.forEach(t => {
processedTerms.push({
term: t,
isColumnSpecific: false
});
});
// Also add the original term in case exact phrase matching is needed
processedTerms.push({
term: termLower,
isColumnSpecific: false
});
} else if (termLower.includes('|')) {
// Handle OR search with pipe character
const orTerms = termLower.split('|').map(t => t.trim()).filter(t => t);
orTerms.forEach(t => {
processedTerms.push({
term: t,
isColumnSpecific: false
});
});
} else {
processedTerms.push({
term: termLower,
isColumnSpecific: false
});
}
});
// For numerical terms, remove leading zeros
const finalTerms = processedTerms.map(item => {
if (/^\d+$/.test(item.term)) {
return {
...item,
term: String(parseInt(item.term, 10))
};
}
return item;
});
return {
terms: finalTerms,
columnSearchTerms
};
}, [searchTerm, activeSearchTerms]);
// Function to check if text contains any of the search terms
const hasSearchMatch = (text, columnId) => {
if (!text || !allSearchTerms || !allSearchTerms.terms || allSearchTerms.terms.length === 0) {
return false;
}
// Convert to string to handle numeric values
const textStr = String(text || '').toLowerCase();
// Check for column-specific terms first
if (columnId && allSearchTerms.columnSearchTerms && allSearchTerms.columnSearchTerms[columnId]) {
// Look for matches from column-specific values
if (allSearchTerms.columnSearchTerms[columnId].some(term =>
textStr.includes(String(term).toLowerCase())
)) {
return true;
}
}
// Check non-column-specific terms
return allSearchTerms.terms
.filter(item => !item.isColumnSpecific) // Only use generic terms
.some(item => textStr.includes(String(item.term).toLowerCase()));
};
// Determine if the guest is running
const isRunning = guest.status?.toLowerCase() === 'running';
// Determine if this is a shared guest by checking if it actually exists on multiple nodes
// First check if it's in the sharedGuestIds map, otherwise fall back to the ID format check
const isSharedGuest = useMemo(() => {
// For guests with VMID 999 or 888, show more detailed logging
if (guest?.vmid === 999 || guest?.vmid === 888) {
console.info('⚠️ Special VMID detected:', {
id: guest?.id,
vmid: guest?.vmid,
name: guest?.name,
isInSharedMap: !!(guest?.id && sharedGuestIds[guest.id]),
hasColonInId: !!(guest?.id && guest.id.includes(':')),
hastate: guest?.hastate
});
}
// Check if the guest has a meaningful HA state (not undefined, not ignored)
// Only guests managed by HA or shared across nodes should show in the HA column
const hasHaState = guest?.hastate && guest.hastate !== 'ignored';
// Is the guest shared across nodes? (exists in sharedGuestIds map or has ':' in id)
const isShared = (guest?.id && sharedGuestIds[guest.id]) ||
(guest?.id && guest.id.includes(':')) ||
!!guest?.shared;
// For debugging: log about 1% of the guests to avoid flooding the console
if (Math.random() < 0.01) {
console.info('Guest HA Status Check:', {
id: guest?.id,
name: guest.name,
hastate: guest.hastate,
isShared,
hasHaState,
shouldShowHaStatus: hasHaState || isShared
});
}
// Return true if the guest is either HA-managed or shared
return hasHaState || isShared;
}, [guest?.id, guest?.vmid, guest?.name, guest?.hastate, guest?.shared, sharedGuestIds]);
// If the guest is running, it's the primary node for this guest
// In development mode, alternate primary/secondary for testing
const isPrimaryNode = useMemo(() => {
const isDevelopmentMode = localStorage.getItem('NODE_ENV') === 'development' ||
localStorage.getItem('use_mock_data') === 'true';
// Use hastate field if available to determine primary status
// Specifically for showing visuals, any guest with a hastate that isn't stopped
// or disabled should be treated as "PRIMARY" (for the visual only)
if (guest?.hastate) {
if (guest.hastate === 'stopped' || guest.hastate === 'disabled') {
return false;
}
// For any other state (started, error, fence, etc), show as primary
return true;
}
// No hastate? Just use the running status directly
return isRunning;
// Previous logic - commented out as it was defaulting most guests to SECONDARY
/*
if (isDevelopmentMode && isSharedGuest) {
// In dev mode, make some guests primary and others secondary for testing
const guestIdNumber = parseInt(guest?.id?.split(':')[0] || guest?.id || '0', 10);
return guestIdNumber % 2 === 0; // Even IDs are primary, odd are secondary
}
// Standard logic for production
return isSharedGuest && isRunning;
*/
}, [isRunning, guest?.hastate]);
// Get metrics for this guest
const cpuMetrics = metrics?.cpu?.[guest?.id] || null;
const memoryMetrics = metrics?.memory?.[guest?.id] || null;
const diskMetrics = metrics?.disk?.[guest?.id] || null;
const networkMetrics = metrics?.network?.[guest?.id] || null;
// Determine guest type
const isVM = guest.type === 'qemu';
const guestTypeLabel = isVM ? 'VM' : 'CT';
const guestTypeColor = isVM ? 'primary' : 'secondary';
// Get the CPU usage value
const cpuUsage = cpuMetrics?.usage ?? 0;
// Get memory usage with unit awareness
const memoryUsage = memoryMetrics?.usagePercent ?? 0;
const memoryUnit = memoryMetrics?.unit || 'bytes';
const memoryTotal = memoryMetrics?.total ?? 0;
const memoryUsed = memoryMetrics?.used ?? 0;
// Get disk usage with unit awareness
const diskUsage = diskMetrics?.usagePercent ?? 0;
const diskUnit = diskMetrics?.unit || 'bytes';
const diskTotal = diskMetrics?.total ?? 0;
const diskUsed = diskMetrics?.used ?? 0;
// Get network rates with unit awareness
const downloadRate = networkMetrics?.inRate ?? 0;
const uploadRate = networkMetrics?.outRate ?? 0;
// Get uptime
const uptime = guest.uptime ?? 0;
// Define column groups for sizing strategy (same as in header)
const fixedNarrowColumns = ['type', 'id', 'status']; // Very narrow columns
const fixedWidthColumns = ['download', 'upload', 'uptime']; // Fixed width for network stats
const autoSizeColumns = ['name', 'node']; // Auto-size to content
const flexibleEqualColumns = ['cpu', 'memory', 'disk']; // Equal width with progress bars
// Calculate visible columns and their groups
const getVisibleColumnGroups = useMemo(() => {
if (!columnOrder || !Array.isArray(columnOrder) || columnOrder.length === 0) {
return { fixedNarrow: [], fixedWidth: [], autoSize: [], flexibleEqual: [] };
}
const visibleColumnIds = columnOrder.filter(id => columnVisibility[id]?.visible);
return {
fixedNarrow: visibleColumnIds.filter(id => fixedNarrowColumns.includes(id)),
fixedWidth: visibleColumnIds.filter(id => fixedWidthColumns.includes(id)),
autoSize: visibleColumnIds.filter(id => autoSizeColumns.includes(id)),
flexibleEqual: visibleColumnIds.filter(id => flexibleEqualColumns.includes(id))
};
}, [columnOrder, columnVisibility]);
// Get visible columns in the correct order
const visibleColumns = useMemo(() => {
if (!columnOrder || !Array.isArray(columnOrder) || columnOrder.length === 0) {
return [];
}
// Filter visible columns and sort them according to columnOrder
return columnOrder
.filter(id => columnVisibility[id]?.visible)
.map(id => ({ id, ...columnVisibility[id] }))
.filter(Boolean);
}, [columnVisibility, columnOrder]);
// Helper function to get width for a column
const getColumnWidth = (columnId) => {
// Define fixed widths for specific columns
const fixedWidths = {
type: '50px', // Just "VM" or "CT"
id: '60px', // Just numeric IDs
status: '50px', // Just the status circle
role: '100px', // Primary/Secondary chip
download: '90px', // Network rates
upload: '90px', // Network rates
uptime: '90px' // Time display
};
// If column has a fixed width, return it
if (fixedWidths[columnId]) {
return fixedWidths[columnId];
}
// For auto-sized columns, use different strategies for name vs node
if (autoSizeColumns.includes(columnId)) {
// Use max-content for node to fit content more closely
if (columnId === 'node') {
return 'max-content';
}
// Use auto for name which can be longer
return 'auto';
}
// For flexible equal columns (cpu, memory, disk), use percentage
if (flexibleEqualColumns.includes(columnId)) {
const visibleFlexColumns = getVisibleColumnGroups.flexibleEqual.length;
if (visibleFlexColumns > 0) {
// Use the same calculation as in the header
return `${Math.floor(100 / visibleFlexColumns)}%`;
}
// Fallback if something goes wrong
return '20%';
}
// Default fallback
return 'auto';
};
// Check if a column is a flexible column
const isFlexibleColumn = (columnId) => {
return flexibleEqualColumns.includes(columnId);
};
// Function to check if a cell contains content that matches search terms
const cellHasMatch = (columnId) => {
if (!columnId) {
return false;
}
// Check if this column has an active threshold filter
if (['cpu', 'memory', 'disk', 'download', 'upload'].includes(columnId)) {
// Debug log to see what filters are being passed
if (columnId === 'cpu') {
console.log('CPU Column Check:', {
columnId,
hasFilters: !!filters,
filterValue: filters?.[columnId],
isActive: filters && filters[columnId] > 0,
filtersObj: filters ? {...filters} : null
});
}
// Handle all possible ways to check for active filters
// 1. Direct filters object check
if (filters && filters[columnId] > 0) {
return true;
}
// 2. Check the activeFilteredColumns object which might have a different structure
if (activeFilteredColumns && activeFilteredColumns[columnId]) {
return true;
}
// 3. Check if we have a direct match with the thresholdColumn
if (thresholdColumn === columnId && thresholdValue > 0) {
return true;
}
// 4. Check if we have a threshold search term in the search context
const hasThresholdSearch = allSearchTerms?.terms?.some(item =>
item.isColumnSpecific &&
item.columnId === columnId &&
item.term.includes('>') &&
/\d/.test(item.term)
);
if (hasThresholdSearch) {
return true;
}
}
// Continue with regular search term matching if there are search terms
if (!allSearchTerms || !allSearchTerms.terms || allSearchTerms.terms.length === 0) {
return false;
}
// Check for column-specific search terms first
const columnPrefixedTerms = allSearchTerms.terms.filter(item => {
if (!item.isColumnSpecific) return false;
return item.columnId === columnId;
});
// If we have column-specific terms targeting this column, it's a match
if (columnPrefixedTerms.length > 0) {
return true;
}
// Get the text content for this cell based on column ID
let cellText;
switch (columnId) {
case 'node':
cellText = getNodeName(guest?.node);
break;
case 'role':
cellText = isSharedGuest ?
(guest?.hastate === 'started' ? 'primary started' :
guest?.hastate === 'stopped' ? 'secondary stopped' :
guest?.hastate === 'migrate' ? 'migrating migration' :
guest?.hastate === 'relocate' ? 'relocating relocation' :
guest?.hastate === 'error' ? 'error' :
guest?.hastate === 'fence' ? 'fence fencing' :
guest?.hastate === 'recovery' ? 'recovery recovering' :
guest?.hastate === 'disabled' ? 'disabled' :
isPrimaryNode ? 'primary' : 'secondary')
: (guest?.hastate && guest.hastate !== 'ignored' ? 'ha managed high-availability' : '');
break;
case 'type':
cellText = isVM ? 'VM virtual machine' : 'CT container';
break;
case 'id':
cellText = extractNumericId(guest?.id);
break;
case 'status':
cellText = isRunning ? 'running' : 'stopped';
break;
case 'name':
cellText = guest?.name;
break;
case 'cpu':
cellText = `cpu ${formatPercentage(cpuUsage)}`;
break;
case 'memory':
cellText = `memory ${formatBytesWithUnit(memoryUsed, memoryUnit)} ${formatBytesWithUnit(memoryTotal, memoryUnit)} ${memoryUsage}%`;
break;
case 'disk':
cellText = `disk ${formatBytesWithUnit(diskUsed, diskUnit)} ${formatBytesWithUnit(diskTotal, diskUnit)} ${diskUsage}%`;
break;
case 'download':
cellText = formatNetworkRate(downloadRate);
break;
case 'upload':
cellText = formatNetworkRate(uploadRate);
break;
case 'uptime':
cellText = isRunning ? formatUptime(uptime) : '—';
break;
default:
cellText = '';
}
// Now check for general search terms that match this cell's content
return hasSearchMatch(cellText, columnId);
};
// Render cell content based on column ID
const renderCellContent = (columnId) => {
if (!columnId) return null;
switch (columnId) {
case 'node':
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="body2"
noWrap
sx={{
fontWeight: isPrimaryNode ? 'bold' : 'normal',
color: isPrimaryNode ? 'primary.main' : nodeTextColor,
}}
>
{getNodeName(guest?.node)}
</Typography>
</Box>
);
case 'role':
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Only show HA status chip if one of these conditions is true:
1. Guest has a meaningful hastate (not ignored and not undefined)
2. Guest is shared among multiple nodes (truly HA-managed)
*/}
{(guest?.hastate && guest.hastate !== 'ignored') ||
(guest?.shared === true || (guest?.id && (sharedGuestIds[guest.id] || guest.id.includes(':')))) ? (
<Tooltip title={
guest?.hastate === 'started' ? "Primary Node - Currently running this shared guest" :
guest?.hastate === 'stopped' ? "Secondary Node - This guest is running on another node" :
guest?.hastate === 'migrate' ? "Migration in Progress - Guest is being moved to another node" :
guest?.hastate === 'relocate' ? "Relocation in Progress - Guest is being cold-migrated" :
guest?.hastate === 'error' ? "Error State - High availability issue detected" :
guest?.hastate === 'fence' ? "Fencing Required - Node needs to be fenced to recover this guest" :
guest?.hastate === 'recovery' ? "Recovery in Progress - Guest is being recovered" :
guest?.hastate === 'disabled' ? "HA Disabled - Guest is not actively managed by HA" :
guest?.hastate === 'ignored' ? "Not managed by High Availability" :
isRunning ? "Primary Node - Currently running this guest" :
"Secondary Node - This guest is not running on this node"
}>
<Chip
size="small"
label={
guest?.hastate === 'started' ? "PRIMARY" :
guest?.hastate === 'stopped' ? "SECONDARY" :
guest?.hastate === 'migrate' ? "MIGRATING" :
guest?.hastate === 'relocate' ? "RELOCATING" :
guest?.hastate === 'error' ? "ERROR" :
guest?.hastate === 'fence' ? "FENCE" :
guest?.hastate === 'recovery' ? "RECOVERY" :
guest?.hastate === 'disabled' ? "DISABLED" :
guest?.hastate === 'ignored' ? "IGNORED" :
isRunning ? "PRIMARY" : "SECONDARY"
}
sx={{
height: '16px',
fontSize: '0.6rem',
backgroundColor:
guest?.hastate === 'started' ? alpha(theme.palette.primary.main, 0.2) :
guest?.hastate === 'stopped' ? alpha(theme.palette.text.secondary, 0.1) :
guest?.hastate === 'migrate' ? alpha(theme.palette.info.main, 0.2) :
guest?.hastate === 'relocate' ? alpha(theme.palette.info.main, 0.2) :
guest?.hastate === 'error' ? alpha(theme.palette.error.main, 0.2) :
guest?.hastate === 'fence' ? alpha(theme.palette.warning.main, 0.2) :
guest?.hastate === 'recovery' ? alpha(theme.palette.warning.main, 0.2) :
guest?.hastate === 'disabled' ? alpha(theme.palette.text.disabled, 0.2) :
guest?.hastate === 'ignored' ? alpha(theme.palette.text.disabled, 0.1) :
isRunning ? alpha(theme.palette.primary.main, 0.2) : alpha(theme.palette.text.secondary, 0.1),
color:
guest?.hastate === 'started' ? theme.palette.primary.main :
guest?.hastate === 'stopped' ? theme.palette.text.secondary :
guest?.hastate === 'migrate' ? theme.palette.info.main :
guest?.hastate === 'relocate' ? theme.palette.info.main :
guest?.hastate === 'error' ? theme.palette.error.main :
guest?.hastate === 'fence' ? theme.palette.warning.main :
guest?.hastate === 'recovery' ? theme.palette.warning.main :
guest?.hastate === 'disabled' ? theme.palette.text.disabled :
guest?.hastate === 'ignored' ? theme.palette.text.disabled :
isRunning ? theme.palette.primary.main : theme.palette.text.secondary,
borderColor:
guest?.hastate === 'started' ? alpha(theme.palette.primary.main, 0.3) :
guest?.hastate === 'stopped' ? alpha(theme.palette.text.secondary, 0.2) :
guest?.hastate === 'migrate' ? alpha(theme.palette.info.main, 0.3) :
guest?.hastate === 'relocate' ? alpha(theme.palette.info.main, 0.3) :
guest?.hastate === 'error' ? alpha(theme.palette.error.main, 0.3) :
guest?.hastate === 'fence' ? alpha(theme.palette.warning.main, 0.3) :
guest?.hastate === 'recovery' ? alpha(theme.palette.warning.main, 0.3) :
guest?.hastate === 'disabled' ? alpha(theme.palette.text.disabled, 0.3) :
guest?.hastate === 'ignored' ? alpha(theme.palette.text.disabled, 0.2) :
isRunning ? alpha(theme.palette.primary.main, 0.3) : alpha(theme.palette.text.secondary, 0.2),
}}
/>
</Tooltip>
) : null}
</Box>
);
case 'type':
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}>
<Tooltip title={isVM ? 'Virtual Machine' : 'Container'}>
<Typography
variant="caption"
sx={{
fontWeight: 'bold',
color: isVM ? theme.palette.primary.main : '#00C853',
fontSize: '0.7rem'
}}
>
{isVM ? 'VM' : 'CT'}
</Typography>
</Tooltip>
</Box>
);
case 'id':
return (
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{extractNumericId(guest?.id)}
</Typography>
);
case 'status':
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}>
{isSharedGuest ? (
<Tooltip title={isPrimaryNode
? `Primary: Running on ${nodeName}`
: `Secondary: Not running on ${nodeName}`
}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<StatusIndicator status={isRunning ? 'running' : 'stopped'} />
</Box>
</Tooltip>
) : (
<Tooltip title={isRunning ? 'Running' : 'Stopped'}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<StatusIndicator status={isRunning ? 'running' : 'stopped'} />
</Box>
</Tooltip>
)}
</Box>
);
case 'name':
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" noWrap>
{guest?.name}
</Typography>
</Box>
);
case 'cpu':
return (
<ProgressWithLabel
value={cpuUsage}
label={`${formatPercentage(cpuUsage)}`}
color={cpuUsage > 90 ? 'error' : cpuUsage > 70 ? 'warning' : 'primary'}
/>
);
case 'memory':
return (
<ProgressWithLabel
value={memoryUsage}
label={`${formatBytesWithUnit(memoryUsed, memoryUnit)} / ${formatBytesWithUnit(memoryTotal, memoryUnit)}`}
color={memoryUsage > 90 ? 'error' : memoryUsage > 70 ? 'warning' : 'primary'}
/>
);
case 'disk':
return (
<ProgressWithLabel
value={diskUsage}
label={`${formatBytesWithUnit(diskUsed, diskUnit)} / ${formatBytesWithUnit(diskTotal, diskUnit)}`}
color={diskUsage > 90 ? 'error' : diskUsage > 70 ? 'warning' : 'primary'}
/>
);
case 'download':
return (
<Typography variant="body2" color="text.secondary">
{formatNetworkRate(downloadRate)}
</Typography>
);
case 'upload':
return (
<Typography variant="body2" color="text.secondary">
{formatNetworkRate(uploadRate)}
</Typography>
);
case 'uptime':
return (
<Typography variant="body2" color="text.secondary">
{isRunning ? formatUptime(uptime) : '—'}
</Typography>
);
default:
return null;
}
};
// Render the table row
return (
<TableRow
hover
sx={{
opacity: isRunning ? 1 : 0.6,
'&:hover': {
opacity: 1
}
}}
>
{/* Render cells based on column order */}
{Array.isArray(visibleColumns) && visibleColumns.length > 0 ? (
visibleColumns.map(column => {
// Don't skip rendering the role column if user chose to make it visible
// Only skip if user hasn't configured it and no cluster detected
const roleColumnShouldDisplay = column.id !== 'role' ||
column.visible === true ||
localStorage.getItem('CLUSTER_DETECTED') === 'true';
if (!roleColumnShouldDisplay) {
return null;
}
// Check if this cell has a matching search term
const hasMatch = cellHasMatch(column.id);
// Special handling for node column to use custom text color
const isNodeColumn = column.id === 'node';
return (
column && (
<TableCell
key={column.id}
sx={{
width: getColumnWidth(column.id),
minWidth: getMinWidthForColumn(column.id),
maxWidth: autoSizeColumns.includes(column.id) ? '300px' : 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
boxSizing: 'border-box',
// Use cell-level highlighting based on text match
backgroundColor: hasMatch
? alpha(theme.palette.primary.main, 0.15)
: 'inherit',
...(fixedNarrowColumns.includes(column.id) && {
textAlign: 'center',
padding: getTableCellPadding(true)
}),
...(!fixedNarrowColumns.includes(column.id) && {
padding: getTableCellPadding(false)
}),
// Special styling for node column
...(isNodeColumn && {
'& .MuiTypography-root': {
color: nodeTextColor,
fontWeight: 500
}
})
}}
>
{renderCellContent(column.id)}
</TableCell>
)
);
})
) : (
<TableCell colSpan={2}>No visible columns</TableCell>
)}
</TableRow>
);
};
// Helper function to get minimum width for each column
const getMinWidthForColumn = (columnId) => {
const minWidths = {
// Fixed narrow columns - add a few pixels to accommodate sort icon
type: 50, // Very small - just "VM" or "CT"
id: 60, // Very small - just numeric IDs
status: 50, // Minimal - just an icon
role: 100, // Primary/Secondary chip
// Auto-sized columns
name: 120, // Names - minimum width
node: 80, // Node names - minimum width (reduced from 100)
// Flexible equal columns
cpu: 140, // Medium - progress bar with percentage
memory: 140, // Progress bar with byte values
disk: 140, // Progress bar with byte values
// Fixed width columns
download: 90, // Network rates
upload: 90, // Network rates
uptime: 90 // Time display
};
return minWidths[columnId] || 80;
};
export default NetworkTableRow;
@@ -1,280 +0,0 @@
import React from 'react';
import {
Box,
Typography,
LinearProgress,
Tooltip,
alpha,
useTheme
} from '@mui/material';
import { formatPercentage } from '../../utils/formatters';
import { pulseAnimation, logoPulseAnimation, centerGlowAnimation } from '../../constants/networkConstants';
// Animated Logo component
export const AnimatedLogo = React.memo(({ size = 32, darkMode = false }) => {
return (
<Box
sx={{
position: 'relative',
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: 2,
}}
>
{/* Background circle with subtle pulse */}
<Box
sx={{
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(58, 123, 213, 0.8), rgba(58, 123, 213, 0.7))',
filter: darkMode ? 'brightness(1.2)' : 'none',
animation: `${pulseAnimation} 4s infinite ease-in-out`,
animationDelay: '0.5s',
}}
/>
{/* Outer ring */}
<Box
sx={{
position: 'absolute',
width: '95%',
height: '95%',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))',
}}
/>
{/* Pulse ring (animated) */}
<Box
sx={{
position: 'absolute',
width: '70%',
height: '70%',
borderRadius: '50%',
border: '2px solid rgba(255, 255, 255, 0.7)',
zIndex: 1,
animation: `${logoPulseAnimation} 2.5s infinite ease-in-out`,
}}
/>
{/* Center dot */}
<Box
sx={{
position: 'absolute',
width: '30%',
height: '30%',
borderRadius: '50%',
background: 'linear-gradient(135deg, #ffffff, #f0f0f0)',
animation: `${centerGlowAnimation} 3s infinite ease-in-out`,
animationDelay: '0.2s',
zIndex: 2,
}}
/>
</Box>
);
});
// Animated Logo with Text component
export const AnimatedLogoWithText = React.memo(({ size = 32, darkMode = false }) => {
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<AnimatedLogo size={size} darkMode={darkMode} />
<Typography
variant="h6"
component="div"
sx={{
fontWeight: 'bold',
color: darkMode ? 'white' : 'inherit',
letterSpacing: '0.5px',
textShadow: darkMode
? '0 2px 4px rgba(0, 0, 0, 0.5), 0 0 2px rgba(0, 0, 0, 0.3)'
: '0 2px 4px rgba(0, 0, 0, 0.3), 0 0 2px rgba(0, 0, 0, 0.2)'
}}
>
Pulse
</Typography>
</Box>
);
});
// Progress bar with label and tooltip
export const ProgressWithLabel = React.memo(({ value, color = "primary", disabled = false, tooltipText }) => {
// Ensure value is a number and between 0-100
const normalizedValue = typeof value === 'number'
? Math.min(Math.max(0, value), 100)
: 0;
const progressBar = (
<Box sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
opacity: disabled ? 0.5 : 1,
}}>
<Box sx={{ minWidth: 35, mr: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontWeight: 500,
fontSize: '0.75rem',
whiteSpace: 'nowrap',
}}
>
{formatPercentage(normalizedValue)}
</Typography>
</Box>
<Box sx={{ width: '100%' }}>
<LinearProgress
variant="determinate"
value={normalizedValue}
color={color}
sx={{
height: 4,
borderRadius: 4,
backgroundColor: theme => alpha(theme.palette.grey[300], 0.5),
'& .MuiLinearProgress-bar': {
borderRadius: 4,
transition: 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}
}}
/>
</Box>
</Box>
);
if (tooltipText) {
return (
<Tooltip title={tooltipText} arrow placement="top">
{progressBar}
</Tooltip>
);
}
return progressBar;
});
// Status indicator circle
export const StatusIndicator = React.memo(({ status }) => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === 'dark';
let color = 'grey';
switch (status.toLowerCase()) {
case 'running':
color = '#4caf50'; // success green
break;
case 'stopped':
color = '#f44336'; // error red
break;
case 'paused':
color = '#ff9800'; // warning orange
break;
default:
color = '#9e9e9e'; // grey
}
return (
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: color,
boxShadow: isDarkMode
? `0 0 0 1px ${alpha(color, 0.5)}`
: '0 0 0 1px rgba(255, 255, 255, 0.8)',
...(status.toLowerCase() === 'running' && {
// No animation for running status
})
}}
/>
);
});
// KeyboardShortcut component with better a11y
export const KeyboardShortcut = ({ shortcut, sx = {} }) => (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 0.7,
py: 0.3,
ml: 0.8,
fontSize: '0.65rem',
lineHeight: 1,
fontWeight: 600,
color: 'text.secondary',
bgcolor: 'rgba(0, 0, 0, 0.06)',
borderRadius: 0.8,
border: '1px solid',
borderColor: 'rgba(0, 0, 0, 0.1)',
...sx
}}
aria-hidden="true" // Hide from screen readers since it's visual decoration
>
{shortcut}
</Box>
);
// Add a new HighlightedText component for search term highlighting
export const HighlightedText = ({ text, searchTerms = [], variant = "body2", sx = {}, ...props }) => {
const theme = useTheme();
if (!text || !searchTerms || searchTerms.length === 0) {
return <Typography variant={variant} sx={sx} {...props}>{text}</Typography>;
}
// Convert to string to handle numeric values
const textStr = String(text || '');
// Prepare regular expression for matching - case insensitive
// Escape special regex characters in search terms
const escapedTerms = searchTerms.map(term =>
String(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
// Create regex pattern from all terms
const pattern = new RegExp(`(${escapedTerms.join('|')})`, 'gi');
// Split text by matches
const parts = textStr.split(pattern);
// Skip highlighting if no matches
if (parts.length <= 1) {
return <Typography variant={variant} sx={sx} {...props}>{text}</Typography>;
}
return (
<Typography variant={variant} sx={sx} {...props}>
{parts.map((part, i) => {
// Check if this part matches any search term (case insensitive)
const isMatch = searchTerms.some(term =>
part.toLowerCase() === String(term).toLowerCase()
);
return isMatch ? (
<Box
component="span"
key={i}
sx={{
backgroundColor: alpha(theme.palette.primary.main, 0.2),
borderRadius: '2px',
padding: '0 2px',
fontWeight: 'medium'
}}
>
{part}
</Box>
) : part;
})}
</Typography>
);
};
@@ -1,32 +0,0 @@
import React from 'react';
import {
Box
} from '@mui/material';
const NetworkHeader = ({
openFilters,
filters,
handleFilterButtonClick,
filterButtonRef
}) => {
return (
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
mt: 1,
px: 1
}}>
{/* Left side - Intentionally empty, removed Dashboard title */}
<Box />
{/* Right side - Actions - Column visibility button has been moved to the main app header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Additional buttons can be added here if needed */}
</Box>
</Box>
);
};
export default NetworkHeader;
@@ -1,29 +0,0 @@
import React from 'react';
import { Snackbar, Alert } from '@mui/material';
const NetworkNotification = ({
snackbarOpen,
snackbarMessage,
snackbarSeverity,
handleSnackbarClose
}) => {
return (
<Snackbar
open={snackbarOpen}
autoHideDuration={2000}
onClose={handleSnackbarClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleSnackbarClose}
severity={snackbarSeverity}
variant="filled"
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
);
};
export default NetworkNotification;
@@ -1,206 +0,0 @@
import React from 'react';
import {
Popover,
Paper,
Box,
Typography,
IconButton,
Button,
Slider
} from '@mui/material';
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
import { useSearchContext } from '../../../context/SearchContext';
const NetworkPopovers = ({
// Filter popover props
filterAnchorEl,
openFilters,
handleCloseFilterPopover,
filters,
updateFilter,
handleSliderDragStart,
handleSliderDragEnd,
resetFilters,
// Formatters
formatPercentage,
formatNetworkRateForFilter
}) => {
// Get clearSearchTerms from context to ensure search terms are cleared when filters are reset
const { clearSearchTerms } = useSearchContext();
// Handle resetting all filters including search terms
const handleResetAllFilters = () => {
resetFilters();
clearSearchTerms();
handleCloseFilterPopover();
};
return (
<>
{/* Filter popover */}
<Popover
id="filter-menu"
anchorEl={filterAnchorEl}
open={openFilters}
onClose={handleCloseFilterPopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: {
borderRadius: 2,
overflow: 'hidden'
}
}}
>
<Paper sx={{ width: 350, p: 0, overflow: 'hidden' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
Resource Thresholds
</Typography>
<Typography variant="caption" color="text.secondary">
Filter systems by resource usage thresholds.
</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/* CPU filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">CPU Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.cpu)}
</Typography>
</Box>
<Slider
id="cpu-filter-slider"
value={filters.cpu}
onChange={(e, newValue) => updateFilter('cpu', newValue)}
onMouseDown={() => handleSliderDragStart('cpu')}
onMouseUp={handleSliderDragEnd}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
aria-labelledby="cpu-usage-slider"
/>
</Box>
{/* Memory filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Memory Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.memory)}
</Typography>
</Box>
<Slider
id="memory-filter-slider"
value={filters.memory}
onChange={(e, newValue) => updateFilter('memory', newValue)}
onMouseDown={() => handleSliderDragStart('memory')}
onMouseUp={handleSliderDragEnd}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
aria-labelledby="memory-usage-slider"
/>
</Box>
{/* Disk filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Disk Usage</Typography>
<Typography variant="caption" color="text.secondary">
{formatPercentage(filters.disk)}
</Typography>
</Box>
<Slider
id="disk-filter-slider"
value={filters.disk}
onChange={(e, newValue) => updateFilter('disk', newValue)}
onMouseDown={() => handleSliderDragStart('disk')}
onMouseUp={handleSliderDragEnd}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={formatPercentage}
aria-labelledby="disk-usage-slider"
/>
</Box>
{/* Download filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Download Rate</Typography>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.download)}
</Typography>
</Box>
<Slider
id="download-filter-slider"
value={filters.download}
onChange={(e, newValue) => updateFilter('download', newValue)}
onMouseDown={() => handleSliderDragStart('download')}
onMouseUp={handleSliderDragEnd}
min={0}
max={100000}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
aria-labelledby="download-rate-slider"
/>
</Box>
{/* Upload filter */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2">Upload Rate</Typography>
<Typography variant="caption" color="text.secondary">
{formatNetworkRateForFilter(filters.upload)}
</Typography>
</Box>
<Slider
id="upload-filter-slider"
value={filters.upload}
onChange={(e, newValue) => updateFilter('upload', newValue)}
onMouseDown={() => handleSliderDragStart('upload')}
onMouseUp={handleSliderDragEnd}
min={0}
max={100000}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={formatNetworkRateForFilter}
aria-labelledby="upload-rate-slider"
/>
</Box>
{/* Reset button */}
<Button
variant="outlined"
startIcon={<FilterAltOffIcon />}
onClick={handleResetAllFilters}
fullWidth
sx={{ mt: 1 }}
>
Reset All Filters
</Button>
</Box>
</Paper>
</Popover>
</>
);
};
export default NetworkPopovers;
@@ -1,272 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Card,
CardContent,
TableContainer,
Table,
Paper,
Box,
alpha,
useTheme
} from '@mui/material';
import NetworkTableHeader from '../NetworkTableHeader';
import NetworkTableBody from '../NetworkTableBody';
import { STORAGE_KEY_COLUMN_VISIBILITY } from '../../../constants/networkConstants';
import axios from 'axios';
const NetworkTable = ({
sortConfig,
requestSort,
columnVisibility,
toggleColumnVisibility,
resetColumnVisibility,
columnMenuAnchorEl,
handleColumnMenuOpen,
handleColumnMenuClose,
openColumnMenu,
forceUpdateCounter,
columnOrder,
setColumnOrder,
activeFilteredColumns,
sortedAndFilteredData,
guestData,
metricsData,
getNodeName,
extractNumericId,
resetFilters,
showStopped,
setShowStopped,
guestTypeFilter,
setGuestTypeFilter,
availableNodes = [],
selectedNode = 'all',
handleNodeChange = () => {},
handleStatusChange = () => {},
handleTypeChange = () => {},
filters = {},
updateFilter = () => {},
handleFilterButtonClick = () => {},
filterButtonRef = null,
openFilters = false,
handleCloseFilterPopover = () => {},
sharedGuestIdMap = {},
updateRoleColumnVisibility = () => {}
}) => {
const theme = useTheme();
// Table container ref
const tableContainerRef = React.useRef(null);
const tableRef = React.useRef(null);
// Get the column index from the column order
const getColumnIndex = (columnId) => {
// First get visible columns in the right order
const visibleColumnIds = columnOrder.filter(id => columnVisibility[id]?.visible);
return visibleColumnIds.indexOf(columnId);
};
// Add useMemo to detect cluster mode from actual node data
const isClusterPresent = useMemo(() => {
// Get all nodes from the current guest data
const nodesInGuestData = guestData?.map(guest => guest.node) || [];
const uniqueNodes = [...new Set(nodesInGuestData)];
// If we have more than one node or if any guest has a hastate
const hasHaStates = guestData?.some(guest =>
guest.hastate &&
guest.hastate !== 'ignored' &&
guest.hastate !== '-'
);
// Check if any guests are marked as shared - another strong indicator of cluster mode
const hasSharedGuests = guestData?.some(guest => guest.shared === true);
// Check for HA resource data by looking for guests with ':' in their ID (sid format)
const hasHaResourceFormat = guestData?.some(guest =>
guest.id && typeof guest.id === 'string' && guest.id.includes(':')
);
// Check if shared guest map has any entries
const hasSharedGuestMapEntries = Object.keys(sharedGuestIdMap || {}).length > 0;
// Log what we're detecting for debugging
console.log('Cluster detection from guest data:', {
uniqueNodes,
nodeCount: uniqueNodes.length,
hasHaStates,
hasSharedGuests,
hasHaResourceFormat,
hasSharedGuestMapEntries,
haStatesFound: guestData?.filter(g => g.hastate && g.hastate !== 'ignored').map(g => g.hastate) || []
});
// Store the result in localStorage for persistence across page loads
const isCluster = uniqueNodes.length > 1 || hasHaStates || hasSharedGuests || hasHaResourceFormat || hasSharedGuestMapEntries;
// Update localStorage - this will trigger the detection in useColumnManagement
const previousValue = localStorage.getItem('CLUSTER_DETECTED');
if (previousValue !== isCluster.toString()) {
console.log('Updating cluster detection state:', isCluster);
localStorage.setItem('CLUSTER_DETECTED', isCluster.toString());
}
return isCluster;
}, [guestData, sharedGuestIdMap]);
// Check if we're in mock data mode
const isMockData = useMemo(() => {
// Check explicit mock data flags first
const explicitMockEnabled = localStorage.getItem('MOCK_DATA_ENABLED') === 'true' ||
localStorage.getItem('use_mock_data') === 'true';
// Only consider localhost as mock data if no explicit setting exists
// This allows us to connect to real Proxmox on localhost without triggering mock mode
const isOnLocalhost = window.location.hostname === 'localhost';
return explicitMockEnabled;
}, []);
// Make a separate debug effect to constantly log status
useEffect(() => {
console.log('CURRENT COLUMN VISIBILITY STATUS:', {
roleColumnVisible: columnVisibility?.role?.visible,
clusterDetected: localStorage.getItem('CLUSTER_DETECTED') === 'true',
isClusterPresent,
isMockData
});
}, [columnVisibility, isClusterPresent, isMockData]);
// Add immediate API check when component mounts
useEffect(() => {
// Only run once on initial mount
const checkClusterStatusDirectly = async () => {
try {
console.log('Directly checking cluster status from API...');
// Check if we're in mock data mode
const isMockData = localStorage.getItem('MOCK_DATA_ENABLED') === 'true' ||
localStorage.getItem('use_mock_data') === 'true';
// For production, check cluster status from the API
const response = await axios.get('/api/cluster-status');
const isCluster = response?.data?.clusterEnabled || false;
console.log('API returned cluster status:', isCluster);
// Update localStorage and column visibility
localStorage.setItem('CLUSTER_DETECTED', isCluster.toString());
// Always enforce the server-reported cluster status for column visibility
// Even in mock data mode, respect the actual cluster status from the server
if (typeof updateRoleColumnVisibility === 'function') {
updateRoleColumnVisibility(isCluster);
console.log(`HA Status column visibility set to ${isCluster} based on API response`);
}
// If not a cluster, force hide the column to ensure it's not visible
if (!isCluster && columnVisibility?.role?.visible) {
console.log('Server confirmed no cluster, forcing HA Status column to hide');
toggleColumnVisibility('role');
}
} catch (error) {
console.error('Error checking cluster status:', error);
// Use client-side detection as fallback
if (isClusterPresent) {
localStorage.setItem('CLUSTER_DETECTED', 'true');
if (typeof updateRoleColumnVisibility === 'function') {
updateRoleColumnVisibility(true);
}
} else {
localStorage.setItem('CLUSTER_DETECTED', 'false');
if (typeof updateRoleColumnVisibility === 'function') {
updateRoleColumnVisibility(false);
}
}
}
};
// Call immediately
checkClusterStatusDirectly();
}, [isClusterPresent, updateRoleColumnVisibility, columnVisibility, toggleColumnVisibility]);
return (
<Card elevation={0} sx={{ borderRadius: 2, overflow: 'hidden', position: 'relative' }}>
<CardContent sx={{
p: 0,
position: 'relative',
overflow: 'hidden',
'&:last-child': { pb: 0 }
}}>
<TableContainer
component={Paper}
elevation={0}
sx={{
borderRadius: 0,
position: 'relative',
overflow: 'hidden'
}}
ref={tableContainerRef}
>
<Table
size="small"
aria-label="systems table"
sx={{
tableLayout: 'auto',
width: '100%',
'& .MuiTableCell-root': {
borderBottom: '1px solid',
borderColor: 'divider'
}
}}
ref={tableRef}
>
<NetworkTableHeader
sortConfig={sortConfig}
requestSort={requestSort}
columnVisibility={columnVisibility}
toggleColumnVisibility={toggleColumnVisibility}
resetColumnVisibility={resetColumnVisibility}
columnMenuAnchorEl={columnMenuAnchorEl}
handleColumnMenuOpen={handleColumnMenuOpen}
handleColumnMenuClose={handleColumnMenuClose}
openColumnMenu={openColumnMenu}
forceUpdateCounter={forceUpdateCounter}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
activeFilteredColumns={activeFilteredColumns}
showStopped={showStopped}
setShowStopped={setShowStopped}
guestTypeFilter={guestTypeFilter}
setGuestTypeFilter={setGuestTypeFilter}
availableNodes={availableNodes}
selectedNode={selectedNode}
handleNodeChange={handleNodeChange}
handleStatusChange={handleStatusChange}
handleTypeChange={handleTypeChange}
filters={filters}
updateFilter={updateFilter}
handleFilterButtonClick={handleFilterButtonClick}
filterButtonRef={filterButtonRef}
openFilters={openFilters}
handleCloseFilterPopover={handleCloseFilterPopover}
/>
<NetworkTableBody
sortedAndFilteredData={sortedAndFilteredData}
guestData={guestData}
metricsData={metricsData}
getNodeName={getNodeName}
extractNumericId={extractNumericId}
columnVisibility={columnVisibility}
columnOrder={columnOrder}
activeFilteredColumns={activeFilteredColumns}
sharedGuestIdMap={sharedGuestIdMap}
filters={filters}
/>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
export default NetworkTable;
@@ -1,4 +0,0 @@
export { default as NetworkHeader } from './NetworkHeader';
export { default as NetworkPopovers } from './NetworkPopovers';
export { default as NetworkNotification } from './NetworkNotification';
export { default as NetworkTable } from './NetworkTable';
@@ -1,8 +0,0 @@
export { default as useNetworkFilters } from './useNetworkFilters';
export { default as useColumnManagement } from './useColumnManagement';
export { default as usePopoverManagement } from './usePopoverManagement';
export { default as useNotifications } from './useNotifications';
export { default as useSortManagement } from './useSortManagement';
export { default as useKeyboardShortcuts } from './useKeyboardShortcuts';
export { default as useActiveFilteredColumns } from './useActiveFilteredColumns';
export { default as useDataProcessing } from './useDataProcessing';
@@ -1,352 +0,0 @@
import { useMemo } from 'react';
import { useSearchContext } from '../../../context/SearchContext';
const useActiveFilteredColumns = ({
filters,
guestTypeFilter,
showStopped,
nodeData
}) => {
// Get search state from context
const { searchTerm, activeSearchTerms } = useSearchContext();
// Determine which columns have active filters
const activeFilteredColumns = useMemo(() => {
const result = {};
// Resource filters
if (filters.cpu > 0) result.cpu = true;
if (filters.memory > 0) result.memory = true;
if (filters.disk > 0) result.disk = true;
if (filters.download > 0) result.netIn = true;
if (filters.upload > 0) result.netOut = true;
// Search terms - determine which column to highlight based on the search term
// This is now only used for UI indicators in header (like filter icons)
// not for highlighting anymore
if (activeSearchTerms.length > 0 || searchTerm) {
const allTerms = [...activeSearchTerms];
if (searchTerm) allTerms.push(searchTerm);
// Process node data for efficient matching
const nodeInfo = processNodeData(nodeData);
// Process each search term to determine which column(s) to highlight
allTerms.forEach(term => {
const termLower = term.trim().toLowerCase();
// Skip empty terms
if (!termLower) return;
// Try to match the term to specific column types
// This now returns an array of column types for partial matches
const columnTypes = identifyColumnTypes(termLower, nodeInfo);
// Apply the appropriate highlighting for all matching column types
if (Array.isArray(columnTypes)) {
columnTypes.forEach(columnType => {
applyColumnHighlighting(result, columnType, termLower);
});
} else if (columnTypes) {
// For backward compatibility - still handle single column type
applyColumnHighlighting(result, columnTypes, termLower);
}
});
}
// Guest type filter affects the type column
if (guestTypeFilter !== 'all') {
result.type = true;
}
// Highlight status column when we're filtering by status (not showing all)
if (showStopped !== null) {
result.status = true;
}
return result;
}, [filters, activeSearchTerms, searchTerm, guestTypeFilter, showStopped, nodeData]);
return activeFilteredColumns;
};
// Helper function to process node data for efficient matching
function processNodeData(nodeData) {
const nodeNames = [];
const nodeIds = [];
const nodePatterns = ['node', 'pve', 'prox', 'cluster', 'host', 'server'];
// Add role-related terms to help with highlighting
const rolePatterns = ['role', 'primary', 'secondary', 'pri', 'sec', 'shared'];
if (nodeData && Array.isArray(nodeData)) {
nodeData.forEach(node => {
if (node.name) nodeNames.push(node.name.toLowerCase());
if (node.id) nodeIds.push(node.id.toLowerCase());
// Create common node name patterns
const name = (node.name || '').toLowerCase();
if (name.includes('-')) {
// Add base name (e.g., 'pve' from 'pve-01')
const baseName = name.split('-')[0];
if (baseName && !nodePatterns.includes(baseName)) {
nodePatterns.push(baseName);
}
// Add prefix with first digit (e.g., 'pve-0' from 'pve-01')
const parts = name.split('-');
if (parts.length > 1 && parts[1].length > 0) {
const prefix = parts[0] + '-' + parts[1][0];
if (!nodePatterns.includes(prefix)) {
nodePatterns.push(prefix);
}
}
}
});
}
return { nodeNames, nodeIds, nodePatterns, rolePatterns };
}
// Helper function to identify which column a search term applies to
const identifyColumnTypes = (termLower, nodeInfo = {}) => {
// Column-specific prefixes
if (termLower.startsWith('status:')) return 'status';
if (termLower.startsWith('type:')) return 'type';
if (termLower.startsWith('node:')) return 'node';
if (termLower.startsWith('role:')) return 'role';
if (termLower.startsWith('cpu:')) return 'cpu';
if (termLower.startsWith('memory:') || termLower.startsWith('mem:')) return 'memory';
if (termLower.startsWith('disk:')) return 'disk';
if (termLower.startsWith('download:') || termLower.startsWith('dl:')) return 'download';
if (termLower.startsWith('upload:') || termLower.startsWith('ul:')) return 'upload';
// Resource keywords (exact matches)
if (['cpu', 'memory', 'mem', 'disk', 'network', 'net'].includes(termLower)) {
if (termLower === 'network' || termLower === 'net') {
return 'network';
}
if (termLower === 'mem') {
return 'memory';
}
return termLower;
}
// Partial resource expressions (e.g., "cpu>")
if (/^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)$/i.test(termLower)) {
const resource = termLower.match(/^(cpu|mem(ory)?|disk|network|net)/i)[0].toLowerCase();
if (resource === 'network' || resource === 'net') {
return 'network';
}
if (resource === 'mem') {
return 'memory';
}
return resource;
}
// Complete resource expressions (e.g., "cpu>50")
const resourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i;
const resourceMatch = termLower.match(resourceExpressionRegex);
if (resourceMatch) {
let resource = resourceMatch[1].toLowerCase();
if (resource === 'network' || resource === 'net') {
return 'network';
}
if (resource === 'mem') {
return 'memory';
}
return resource;
}
// Direct threshold expressions without column prefix (e.g., ">50")
const directThresholdRegex = /^(>|<|>=|<=)(\d+)$/;
const directThresholdMatch = termLower.match(directThresholdRegex);
if (directThresholdMatch) {
// This is likely a CPU filter as it's the most common direct threshold
return 'cpu';
}
// Column-specific searches with colon (e.g., "name:ubuntu")
if (termLower.includes(':')) {
const [prefix] = termLower.split(':', 2);
const validPrefixes = ['name', 'id', 'node', 'status', 'type', 'cpu', 'memory', 'mem', 'disk', 'network', 'net'];
if (validPrefixes.includes(prefix.trim())) {
const prefixTrim = prefix.trim();
if (prefixTrim === 'network' || prefixTrim === 'net') {
return 'network';
}
if (prefixTrim === 'mem') {
return 'memory';
}
return prefixTrim;
}
}
// For short partial terms (1-2 characters), we'll highlight multiple potential matches
if (termLower.length <= 2) {
const matchingColumns = [];
// Check for partial matches in all possible terms
// Resource terms
const resourceTerms = ['cpu', 'memory', 'disk', 'network', 'net'];
for (const resource of resourceTerms) {
if (resource.startsWith(termLower)) {
matchingColumns.push(resource === 'network' || resource === 'net' ? 'network' : resource);
}
}
// Type terms
const typeTerms = ['ct', 'container', 'vm', 'virtual', 'machine', 'qemu', 'lxc'];
if (typeTerms.some(type => type.startsWith(termLower))) {
matchingColumns.push('type');
}
// Status terms
const statusTerms = ['running', 'stopped', 'online', 'offline', 'active', 'inactive'];
if (statusTerms.some(status => status.startsWith(termLower))) {
matchingColumns.push('status');
}
// Node terms - check if term matches beginning of any node name/id
const { nodeNames, nodeIds } = nodeInfo;
if (nodeNames.some(name => name.startsWith(termLower)) ||
nodeIds.some(id => id.startsWith(termLower))) {
matchingColumns.push('node');
}
// For single letters, also include ID and name as potential matches
if (termLower.length === 1) {
// Always include name for single letter (could be start of any name)
if (!matchingColumns.includes('name')) matchingColumns.push('name');
// For single digit, include ID
if (/^\d$/.test(termLower) && !matchingColumns.includes('id')) {
matchingColumns.push('id');
}
}
// If we found any matches, return them
if (matchingColumns.length > 0) {
return matchingColumns;
}
// For 1-2 character alpha terms with no specific matches,
// default to highlighting name column as it's the most likely target
if (/[a-z]/i.test(termLower)) {
return ['name'];
}
}
// Past this point, handle longer terms (3+ chars) with more specific highlighting
// Type-specific terms (exact matches)
if (['ct', 'container', 'vm', 'virtual machine', 'qemu', 'lxc'].includes(termLower)) {
return 'type';
}
// Status-specific terms (exact matches)
const statusTerms = ['running', 'stopped', 'online', 'offline', 'active', 'inactive'];
if (statusTerms.includes(termLower)) {
return 'status';
}
// Partial status term matches (e.g., "runni" should match "running")
// This is critical for providing good feedback while typing
if (statusTerms.some(status => status.startsWith(termLower) || termLower.startsWith(status))) {
return 'status';
}
// Partial type term matches (e.g., "conta" should match "container")
const typeTerms = ['ct', 'container', 'vm', 'virtual', 'machine', 'qemu', 'lxc'];
if (typeTerms.some(type => type.startsWith(termLower) || termLower.startsWith(type))) {
return 'type';
}
// ID-specific terms (pure numbers)
if (/^\d+$/.test(termLower)) {
return 'id';
}
// Node matching
const { nodeNames, nodeIds, nodePatterns, rolePatterns } = nodeInfo;
// Exact node name/id match
if (nodeNames.includes(termLower) || nodeIds.includes(termLower)) {
return 'node';
}
// Node name contains term or term contains node name
if (nodeNames.some(name => name.includes(termLower) || termLower.includes(name))) {
return 'node';
}
// Node id contains term or term contains node id
if (nodeIds.some(id => id.includes(termLower) || termLower.includes(id))) {
return 'node';
}
// Term matches node patterns
if (nodePatterns.some(pattern => termLower.includes(pattern))) {
return 'node';
}
// Term matches role patterns
if (rolePatterns && rolePatterns.some(pattern => termLower.includes(pattern))) {
return 'role';
}
// Term is likely a node reference
if (termLower.includes('-') || /^[a-z]{1,3}\d{1,2}$/i.test(termLower)) {
return 'node';
}
// Default - this is likely a name search
// Only highlight name if it's likely to be a name search and
// doesn't match any partial patterns above
if (/[a-z]/i.test(termLower) && termLower.length > 1) {
return 'name';
}
// If we can't determine a specific column, don't highlight anything
return null;
}
// Helper function to apply highlighting based on column type
function applyColumnHighlighting(result, columnType, term) {
if (!columnType) return;
switch (columnType) {
case 'cpu':
result.cpu = true;
break;
case 'memory':
result.memory = true;
break;
case 'disk':
result.disk = true;
break;
case 'network':
result.netIn = true;
result.netOut = true;
break;
case 'node':
result.node = true;
break;
case 'id':
result.id = true;
break;
case 'status':
result.status = true;
break;
case 'type':
result.type = true;
break;
case 'name':
result.name = true;
break;
}
}
export default useActiveFilteredColumns;
@@ -1,253 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import {
STORAGE_KEY_COLUMN_VISIBILITY,
STORAGE_KEY_COLUMN_ORDER,
DEFAULT_COLUMN_CONFIG
} from '../../../constants/networkConstants';
const useColumnManagement = (showNotification) => {
// Initialize column visibility state from localStorage or defaults
const [columnVisibility, setColumnVisibility] = useState(() => {
try {
// Always enable cluster detection for column visibility
localStorage.setItem('CLUSTER_DETECTED', 'true');
console.log('Forcing HA Status column to be visible by default');
// Get column visibility from localStorage
const storedColumnVisibility = localStorage.getItem(STORAGE_KEY_COLUMN_VISIBILITY);
let config;
// If localStorage has data, use it
if (storedColumnVisibility) {
config = JSON.parse(storedColumnVisibility);
} else {
// Use the defaults as defined in DEFAULT_COLUMN_CONFIG
config = JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG));
// Ensure the 'role' column (HA Status) is always visible by default
if (config.role) {
config.role.visible = true;
}
}
return config;
} catch (error) {
console.error('Error initializing column visibility:', error);
return DEFAULT_COLUMN_CONFIG;
}
});
// Initialize column order from localStorage or defaults
const [columnOrder, setColumnOrder] = useState(() => {
try {
const storedOrder = localStorage.getItem(STORAGE_KEY_COLUMN_ORDER);
if (storedOrder) {
return JSON.parse(storedOrder);
} else {
return Object.keys(DEFAULT_COLUMN_CONFIG);
}
} catch (error) {
console.error('Error initializing column order:', error);
return Object.keys(DEFAULT_COLUMN_CONFIG);
}
});
// Column menu state for the dropdown
const [columnMenuAnchorEl, setColumnMenuAnchorEl] = useState(null);
const openColumnMenu = Boolean(columnMenuAnchorEl);
// Force update counter for triggering re-renders
const [forceUpdateCounter, setForceUpdateCounter] = useState(0);
// Handle column menu open
const handleColumnMenuOpen = useCallback((event) => {
setColumnMenuAnchorEl(event.currentTarget);
}, []);
// Handle column menu close
const handleColumnMenuClose = useCallback(() => {
setColumnMenuAnchorEl(null);
}, []);
// Force update function
const forceUpdate = useCallback(() => {
setForceUpdateCounter(c => c + 1);
}, []);
// Toggle column visibility
const toggleColumnVisibility = useCallback((columnId) => {
setColumnVisibility(prev => {
const newState = JSON.parse(JSON.stringify(prev));
// Verify column exists
if (!newState[columnId]) {
console.warn(`Attempted to toggle non-existent column: ${columnId}`);
return prev;
}
// Don't allow hiding the last visible column
if (newState[columnId].visible && Object.values(newState).filter(col => col.visible).length <= 1) {
if (showNotification) {
showNotification('At least one column must remain visible');
}
return prev;
}
// Toggle visibility
newState[columnId].visible = !newState[columnId].visible;
// If this is the role column, log it specially
if (columnId === 'role') {
console.log(`User manually set HA Status column to ${newState[columnId].visible ? 'visible' : 'hidden'}`);
}
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_VISIBILITY, JSON.stringify(newState));
return newState;
});
}, [showNotification]);
// Reset column visibility to defaults
const resetColumnVisibility = useCallback(() => {
try {
// Make a copy of the default config
const defaultConfig = JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG));
// Ensure HA Status (role) column is visible
if (defaultConfig.role) {
defaultConfig.role.visible = true;
}
// Reset column order to the default order as well
const defaultOrder = Object.keys(DEFAULT_COLUMN_CONFIG);
// Update state and save to localStorage
setColumnVisibility(defaultConfig);
setColumnOrder(defaultOrder);
// Always enable cluster detection for HA Status column
localStorage.setItem('CLUSTER_DETECTED', 'true');
// Save both to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_VISIBILITY, JSON.stringify(defaultConfig));
localStorage.setItem(STORAGE_KEY_COLUMN_ORDER, JSON.stringify(defaultOrder));
// Force UI update
setForceUpdateCounter(c => c + 1);
// Close the menu if it's open
setColumnMenuAnchorEl(null);
// Show a notification
if (showNotification) {
showNotification('Column visibility reset to defaults');
}
console.log('Column visibility and order reset to defaults');
} catch (error) {
console.error('Error resetting column visibility:', error);
if (showNotification) {
showNotification('Error resetting column visibility');
}
}
}, [showNotification, setColumnMenuAnchorEl]);
// Update role column visibility
const updateRoleColumnVisibility = useCallback((shouldShow) => {
// Log what we're doing
console.log('updateRoleColumnVisibility called with:', {
shouldShow
});
// Save the cluster detection state to localStorage for persistence
localStorage.setItem('CLUSTER_DETECTED', shouldShow ? 'true' : 'false');
// Only update column visibility for new users or on explicit calls
// This allows the system to set default visibility but lets users override
const hasExistingVisibilitySettings = localStorage.getItem(STORAGE_KEY_COLUMN_VISIBILITY);
// Only force column visibility if there are no existing user settings
if (!hasExistingVisibilitySettings) {
setColumnVisibility(prev => {
if (!prev.role) return prev;
const newState = {
...prev,
role: { ...prev.role, visible: shouldShow }
};
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_VISIBILITY, JSON.stringify(newState));
console.log(`Setting initial HA Status column visibility to ${shouldShow}`);
return newState;
});
// Force UI update
setForceUpdateCounter(c => c + 1);
}
}, []);
// Save column visibility preferences whenever they change
useEffect(() => {
try {
// Check if columnVisibility is valid
if (!columnVisibility || Object.keys(columnVisibility).length === 0) {
console.error('Invalid column visibility state, resetting to defaults');
setColumnVisibility(JSON.parse(JSON.stringify(DEFAULT_COLUMN_CONFIG)));
return;
}
// Ensure at least one column is visible
const hasVisibleColumn = Object.values(columnVisibility).some(col => col.visible);
if (!hasVisibleColumn) {
console.error('No visible columns in state, not saving to localStorage');
return;
}
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_VISIBILITY, JSON.stringify(columnVisibility));
} catch (error) {
console.error('Error saving column visibility:', error);
}
}, [columnVisibility]);
// Save column order preferences whenever they change
useEffect(() => {
try {
// Check if columnOrder is valid
if (!columnOrder || !Array.isArray(columnOrder) || columnOrder.length === 0) {
console.error('Invalid column order state, resetting to defaults');
setColumnOrder(Object.keys(DEFAULT_COLUMN_CONFIG));
return;
}
// Save to localStorage
localStorage.setItem(STORAGE_KEY_COLUMN_ORDER, JSON.stringify(columnOrder));
} catch (error) {
console.error('Error saving column order:', error);
}
}, [columnOrder]);
return {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
columnMenuAnchorEl,
setColumnMenuAnchorEl,
openColumnMenu,
handleColumnMenuOpen,
handleColumnMenuClose,
forceUpdateCounter,
forceUpdate,
toggleColumnVisibility,
resetColumnVisibility,
updateRoleColumnVisibility
};
};
export default useColumnManagement;
@@ -1,185 +0,0 @@
import { useMemo, useCallback } from 'react';
import { getSortedAndFilteredData, getNodeFilteredGuests as nodeFilteredGuestsUtil, getNodeName as getNodeNameUtil, extractNumericId as extractNumericIdUtil } from '../../../utils/networkUtils';
import { useSearchContext } from '../../../context/SearchContext';
const useDataProcessing = ({
guestData,
nodeData,
sortConfig,
filters,
showStopped,
selectedNode,
guestTypeFilter,
metricsData
}) => {
// Get search state from context
const { searchTerm, activeSearchTerms } = useSearchContext();
// Helper function to extract numeric ID from strings like "node-1-ct-105"
const extractNumericId = useCallback((fullId) => {
return extractNumericIdUtil(fullId);
}, []);
// Helper function to get the node name from the node ID
const getNodeName = useCallback((nodeId) => {
return getNodeNameUtil(nodeId, nodeData);
}, [nodeData]);
// Filter guests based on selected node
const getNodeFilteredGuests = useCallback((guests) => {
const result = nodeFilteredGuestsUtil(guests, selectedNode);
console.log(`Node filtering for "${selectedNode}":`);
console.log(`- Before: ${guests?.length || 0} guests`);
console.log(`- After: ${result?.length || 0} guests`);
if (result?.length > 0) {
// Log node assignments in the filtered result
const nodeAssignments = {};
result.forEach(guest => {
const nodeId = guest.node;
if (!nodeAssignments[nodeId]) {
nodeAssignments[nodeId] = [];
}
nodeAssignments[nodeId].push(`${guest.id} (${guest.name})`);
});
console.log('FILTERED GUESTS BY NODE:');
Object.keys(nodeAssignments).sort().forEach(nodeId => {
console.log(`Node "${nodeId}": ${nodeAssignments[nodeId].length} guests`);
console.log(` Guests: ${nodeAssignments[nodeId].join(', ')}`);
});
}
return result;
}, [selectedNode]);
// Get sorted and filtered data
const processedData = useMemo(() => {
// Debug logging
console.log('useDataProcessing - Processing data:');
console.log('- guestData:', guestData?.length || 0, 'guests');
console.log('- selectedNode:', selectedNode);
console.log('- sortConfig:', sortConfig);
// Check for duplicate guest IDs
if (guestData && guestData.length > 0) {
const guestIds = new Set();
const duplicates = [];
guestData.forEach(guest => {
if (guestIds.has(guest.id)) {
duplicates.push(guest.id);
} else {
guestIds.add(guest.id);
}
});
if (duplicates.length > 0) {
console.warn('⚠️ Found duplicate guest IDs:', duplicates);
}
// Check for guests with unexpected node values
const nodeAssignments = {};
guestData.forEach(guest => {
const nodeId = guest.node;
if (!nodeAssignments[nodeId]) {
nodeAssignments[nodeId] = [];
}
nodeAssignments[nodeId].push(`${guest.id}`);
});
console.log('🔍 Current guest node assignments:');
Object.keys(nodeAssignments).sort().forEach(nodeId => {
// Check if this is one of our problem nodes
const isWatchNode = ['pve-prod-01', 'pve-prod-02'].includes(nodeId);
const marker = isWatchNode ? '⚠️' : '✅';
console.log(`${marker} Node "${nodeId}": ${nodeAssignments[nodeId].length} guests`);
// For problem nodes, list all the guests
if (isWatchNode) {
console.log(` Guests: ${nodeAssignments[nodeId].join(', ')}`);
}
});
}
// First filter by node
const nodeFilteredData = selectedNode === 'all'
? guestData
: getNodeFilteredGuests(guestData);
console.log('- nodeFilteredData:', nodeFilteredData?.length || 0, 'guests after node filtering');
// Then apply all other filters and sorting
const result = getSortedAndFilteredData(
nodeFilteredData,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
metricsData,
guestTypeFilter,
nodeData
);
// Final detailed logging to debug the issue
if (result && result.length > 0) {
console.log(`🔍 FINAL: ${result.length} guests will be displayed`);
const finalNodeAssignments = {};
result.forEach(guest => {
const nodeId = guest.node || 'unknown';
if (!finalNodeAssignments[nodeId]) {
finalNodeAssignments[nodeId] = [];
}
finalNodeAssignments[nodeId].push(guest.id);
});
console.log('🔍 FINAL node distribution:');
Object.keys(finalNodeAssignments).sort().forEach(nodeId => {
console.log(` - ${nodeId}: ${finalNodeAssignments[nodeId].length} guests`);
console.log(` Guest IDs: ${finalNodeAssignments[nodeId].join(', ')}`);
});
}
console.log('- Final filtered data:', result?.length || 0, 'guests');
return result;
}, [
guestData,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
selectedNode,
getNodeFilteredGuests,
guestTypeFilter,
nodeData,
metricsData
]);
// Format percentage for display
const formatPercentage = useCallback((value) => {
return `${value}%`;
}, []);
// Format network rate for filter display
const formatNetworkRateForFilter = useCallback((value) => {
if (value === 0) return '0 KB/s';
if (value <= 10) return `${value * 10} KB/s`;
if (value <= 50) return `${(value - 10) * 20 + 100} KB/s`;
if (value <= 80) return `${(value - 50) * 50 + 900} KB/s`;
return `${(value - 80) * 500 + 2400} KB/s`;
}, []);
return {
extractNumericId,
getNodeName,
getNodeFilteredGuests,
processedData,
formatPercentage,
formatNetworkRateForFilter
};
};
export default useDataProcessing;
@@ -1,134 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchContext } from '../../../context/SearchContext';
const useKeyboardShortcuts = ({
openFilters,
openColumnMenu,
resetFilters,
closeAllPopovers,
showNotification
}) => {
const [escRecentlyPressed, setEscRecentlyPressed] = useState(false);
// Get search functions from context directly
const { setSearchTerm, setIsSearching, clearSearchTerms, isSearching } = useSearchContext();
// Track last key pressed timestamp to prevent double handling of events
const lastKeyPressTime = useRef(0);
// Set up keyboard shortcuts
useEffect(() => {
// Global keyboard handler
const handleGlobalKeyDown = (e) => {
// Critical protection against double event handling
// This prevents the same key event from being processed twice
const now = Date.now();
if (now - lastKeyPressTime.current < 50) {
return; // Ignore events that are too close together
}
lastKeyPressTime.current = now;
// STOP HANDLING KEYBOARD SHORTCUTS IF USER IS TYPING IN A FORM ELEMENT
// This is the most important check to prevent shortcuts from interfering with typing
const isEditableElement =
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable ||
e.target.getAttribute('role') === 'textbox';
// If user is typing in ANY input, don't hijack their keystrokes
if (isEditableElement) {
return;
}
// Escape key to close filters or clear search
if (e.key === 'Escape') {
// Close any open popovers
closeAllPopovers();
// Reset all filters
resetFilters();
// Clear all search terms
clearSearchTerms();
// Set flag to prevent other shortcuts from triggering
setEscRecentlyPressed(true);
setTimeout(() => setEscRecentlyPressed(false), 300);
// Show notification
showNotification('All filters have been cleared', 'info');
return;
}
// Ctrl+F or Cmd+F to focus search without setting a term
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
setIsSearching(true);
return;
}
// / to focus search without setting a term
if (e.key === '/' && !escRecentlyPressed) {
e.preventDefault();
setIsSearching(true);
return;
}
// Enter to focus search without setting a term
if (e.key === 'Enter' && !escRecentlyPressed) {
e.preventDefault();
setIsSearching(true);
return;
}
// Respect the search state
if (isSearching) {
// If the search field is focused, don't handle keyboard shortcuts
return;
}
// Capture single printable characters to focus search AND start typing
const isPrintableChar =
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.key.match(/^F\d+$/); // Exclude function keys
if (isPrintableChar) {
e.preventDefault();
// Set the first character
setSearchTerm(e.key);
// Focus the search field
setIsSearching(true);
}
};
// Use the capture phase to get events before other handlers
window.addEventListener('keydown', handleGlobalKeyDown);
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown);
};
}, [
openFilters,
openColumnMenu,
escRecentlyPressed,
resetFilters,
closeAllPopovers,
setSearchTerm,
setIsSearching,
clearSearchTerms,
showNotification,
isSearching
]);
return {
escRecentlyPressed
};
};
export default useKeyboardShortcuts;
@@ -1,158 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import {
STORAGE_KEY_FILTERS,
STORAGE_KEY_SHOW_STOPPED,
STORAGE_KEY_SHOW_FILTERS,
STORAGE_KEY_GUEST_TYPE_FILTER
} from '../../../constants/networkConstants';
const useNetworkFilters = () => {
// Filter states
const [filters, setFilters] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
return saved ? JSON.parse(saved) : {
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
};
} catch (e) {
console.error('Error loading filter preferences:', e);
return {
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
};
}
});
// UI state
const [showStopped, setShowStopped] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SHOW_STOPPED);
if (saved === null) return null; // If no saved preference, show all systems
const parsedValue = JSON.parse(saved);
// Convert the old boolean values to the new tri-state system
if (parsedValue === true) return true; // Show stopped systems
if (parsedValue === false) return false; // Show running systems
return parsedValue; // Return the value as is (should be null, true, or false)
} catch (e) {
console.error('Error loading show stopped preference:', e);
return null; // Default to showing all systems
}
});
const [showFilters, setShowFilters] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SHOW_FILTERS);
return saved ? JSON.parse(saved) === true : false;
} catch (e) {
console.error('Error loading show filters preference:', e);
return false;
}
});
// Add guest type filter state - load from localStorage or use default
const [guestTypeFilter, setGuestTypeFilter] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_GUEST_TYPE_FILTER);
return saved ? JSON.parse(saved) : 'all'; // Default: 'all' (show both VMs and LXCs)
} catch (e) {
console.error('Error loading guest type filter preference:', e);
return 'all'; // Default to showing all guest types
}
});
// State for tracking which slider is being dragged
const [sliderDragging, setSliderDragging] = useState(null);
// Save guest type filter preference whenever it changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY_GUEST_TYPE_FILTER, JSON.stringify(guestTypeFilter));
} catch (e) {
console.error('Error saving guest type filter preference:', e);
}
}, [guestTypeFilter]);
// Save show stopped preference whenever it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SHOW_STOPPED, JSON.stringify(showStopped));
}, [showStopped]);
// Save show filters preference whenever it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SHOW_FILTERS, JSON.stringify(showFilters));
}, [showFilters]);
// Save filter preferences whenever they change
useEffect(() => {
localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(filters));
}, [filters]);
// Update filter value
const updateFilter = useCallback((filterName, newValue) => {
setFilters(prev => ({
...prev,
[filterName]: newValue
}));
}, []);
// Handle slider drag start
const handleSliderDragStart = useCallback((filterName) => {
setSliderDragging(filterName);
}, []);
// Handle slider drag end
const handleSliderDragEnd = useCallback(() => {
setSliderDragging(null);
}, []);
// Clear a specific filter
const clearFilter = useCallback((filterName) => {
setFilters(prev => ({
...prev,
[filterName]: 0
}));
}, []);
// Function to reset all filters
const resetFilters = useCallback(() => {
setFilters({
cpu: 0,
memory: 0,
disk: 0,
download: 0,
upload: 0
});
setShowStopped(null);
setGuestTypeFilter('all');
}, []);
// Count active filters - now only count the slider filters
const activeFilterCount = Object.values(filters).filter(val => val > 0).length;
return {
filters,
setFilters,
showStopped,
setShowStopped,
showFilters,
setShowFilters,
guestTypeFilter,
setGuestTypeFilter,
sliderDragging,
updateFilter,
handleSliderDragStart,
handleSliderDragEnd,
clearFilter,
resetFilters,
activeFilterCount
};
};
export default useNetworkFilters;
@@ -1,33 +0,0 @@
import { useState, useCallback } from 'react';
const useNotifications = () => {
// Snackbar state for notifications
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarSeverity, setSnackbarSeverity] = useState('info');
// Handle snackbar close
const handleSnackbarClose = useCallback((event, reason) => {
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
}, []);
// Show a notification
const showNotification = useCallback((message, severity = 'info') => {
setSnackbarMessage(message);
setSnackbarSeverity(severity);
setSnackbarOpen(true);
}, []);
return {
snackbarOpen,
snackbarMessage,
snackbarSeverity,
handleSnackbarClose,
showNotification
};
};
export default useNotifications;
@@ -1,35 +0,0 @@
import { useState, useRef, useCallback } from 'react';
const usePopoverManagement = () => {
// State for filter menu
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
const openFilters = Boolean(filterAnchorEl);
// Add a ref for the filter button
const filterButtonRef = useRef(null);
// Filter popover handlers
const handleFilterButtonClick = useCallback((event) => {
setFilterAnchorEl(event.currentTarget);
}, []);
const handleCloseFilterPopover = useCallback(() => {
setFilterAnchorEl(null);
}, []);
// Function to close all popovers
const closeAllPopovers = useCallback(() => {
setFilterAnchorEl(null);
}, []);
return {
filterAnchorEl,
openFilters,
filterButtonRef,
handleFilterButtonClick,
handleCloseFilterPopover,
closeAllPopovers
};
};
export default usePopoverManagement;
@@ -1,61 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { STORAGE_KEY_SORT } from '../../../constants/networkConstants';
const useSortManagement = () => {
// Sort state
const [sortConfig, setSortConfig] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SORT);
return saved ? JSON.parse(saved) : { key: "node", direction: 'asc' };
} catch (e) {
console.error('Error loading sort preferences:', e);
return { key: "node", direction: 'asc' };
}
});
// Request sort by key
const requestSort = useCallback((key, forcedDirection) => {
console.log(`Sorting by ${key}${forcedDirection ? ' with forced direction: ' + forcedDirection : ''}`);
setSortConfig(prev => {
// If a forced direction is provided, use that
if (forcedDirection) {
const newConfig = {
key,
direction: forcedDirection
};
console.log(`Setting sort: ${key}${forcedDirection} (forced)`);
return newConfig;
}
// Otherwise toggle direction if same key, or default to ascending for new key
const newDirection = prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc';
const newConfig = {
key,
direction: newDirection
};
console.log(`Setting sort: ${key}${newDirection} (toggled from ${prev.key === key ? prev.direction : 'new column'})`);
return newConfig;
});
}, []);
// Log sort config changes for debugging
useEffect(() => {
console.log('Current sort config:', sortConfig);
}, [sortConfig]);
// Save sort preferences whenever they change
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SORT, JSON.stringify(sortConfig));
}, [sortConfig]);
return {
sortConfig,
setSortConfig,
requestSort
};
};
export default useSortManagement;
@@ -1,88 +0,0 @@
import { keyframes } from '@mui/material';
// Define pulse animation for background
export const pulseAnimation = keyframes`
0% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.02);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.9;
}
`;
// Define logo pulse animation - more subtle and elegant for the logo
export const logoPulseAnimation = keyframes`
0% {
opacity: 0.8;
transform: scale(0.98);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
50% {
opacity: 1;
transform: scale(1.02);
box-shadow: 0 0 6px 1px rgba(255, 255, 255, 0.3);
}
100% {
opacity: 0.8;
transform: scale(0.98);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
`;
// Define center dot glow animation
export const centerGlowAnimation = keyframes`
0% {
box-shadow: 0 0 4px 0px rgba(255, 255, 255, 0.5);
}
50% {
box-shadow: 0 0 8px 2px rgba(255, 255, 255, 0.7);
}
100% {
box-shadow: 0 0 4px 0px rgba(255, 255, 255, 0.5);
}
`;
// Add fade-in animation
export const fadeIn = keyframes`
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
// Add localStorage keys as constants
export const STORAGE_KEY_FILTERS = 'network_display_filters';
export const STORAGE_KEY_SORT = 'network_display_sort';
export const STORAGE_KEY_SHOW_STOPPED = 'network_display_show_stopped';
export const STORAGE_KEY_SHOW_FILTERS = 'network_display_show_filters';
export const STORAGE_KEY_SEARCH_TERMS = 'network_display_search_terms';
export const STORAGE_KEY_COLUMN_VISIBILITY = 'network_display_column_visibility';
export const STORAGE_KEY_COLUMN_ORDER = 'network_display_column_order';
export const STORAGE_KEY_COLUMN_DRAG_ENABLED = 'network_display_column_drag_enabled';
export const STORAGE_KEY_GUEST_TYPE_FILTER = 'network_display_guest_type_filter';
// Define default column configuration
export const DEFAULT_COLUMN_CONFIG = {
name: { id: 'name', label: 'Name', visible: true },
status: { id: 'status', label: 'Status', visible: true },
node: { id: 'node', label: 'Node', visible: true },
role: { id: 'role', label: 'HA Status', visible: true },
type: { id: 'type', label: 'Type', visible: true },
id: { id: 'id', label: 'ID', visible: true },
cpu: { id: 'cpu', label: 'CPU', visible: true },
memory: { id: 'memory', label: 'Memory', visible: true },
disk: { id: 'disk', label: 'Disk', visible: true },
download: { id: 'download', label: 'Download', visible: true },
upload: { id: 'upload', label: 'Upload', visible: true },
uptime: { id: 'uptime', label: 'Uptime', visible: true }
};
-113
View File
@@ -1,113 +0,0 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import { STORAGE_KEY_SEARCH_TERMS } from '../constants/networkConstants';
// Create the context
export const SearchContext = createContext({
searchTerm: '',
setSearchTerm: () => {},
activeSearchTerms: [],
addSearchTerm: () => {},
removeSearchTerm: () => {},
clearSearchTerms: () => {},
isSearching: false,
setIsSearching: () => {},
handleSpecialSearchTerm: () => {},
});
// Custom hook to use the search context
export const useSearchContext = () => useContext(SearchContext);
// Search provider component
export const SearchProvider = ({ children }) => {
// Search state
const [searchTerm, setSearchTermInternal] = useState('');
const [activeSearchTerms, setActiveSearchTerms] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SEARCH_TERMS);
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error('Error loading active search terms:', e);
return [];
}
});
const [isSearching, setIsSearching] = useState(false);
// Wrap setSearchTerm to add logging for debugging
const setSearchTerm = useCallback((value) => {
console.log('SearchContext: setSearchTerm called with value:', value);
// Make sure we always have a string, even if undefined is passed
const safeValue = value === undefined ? '' : value;
setSearchTermInternal(safeValue);
}, []);
// Save active search terms whenever they change
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SEARCH_TERMS, JSON.stringify(activeSearchTerms));
}, [activeSearchTerms]);
// Function to handle special search terms (status:, type:, etc.)
const handleSpecialSearchTerm = useCallback((term, action = 'add') => {
const termLower = term.toLowerCase().trim();
const isAdding = action === 'add';
// Create a custom event to dispatch
const event = new CustomEvent('searchTermAction', {
detail: {
term: termLower,
action: isAdding ? 'add' : 'remove'
}
});
// Dispatch the event for other components to listen to
window.dispatchEvent(event);
}, []);
// Function to add a search term
const addSearchTerm = useCallback((term) => {
if (!activeSearchTerms.includes(term) && term.trim()) {
setActiveSearchTerms(prev => [...prev, term]);
// Handle special search terms
handleSpecialSearchTerm(term, 'add');
}
}, [activeSearchTerms, handleSpecialSearchTerm]);
// Function to remove a search term
const removeSearchTerm = useCallback((term) => {
setActiveSearchTerms(prev => prev.filter(t => t !== term));
// Handle special search terms
handleSpecialSearchTerm(term, 'remove');
}, [handleSpecialSearchTerm]);
// Function to clear all search terms
const clearSearchTerms = useCallback(() => {
// Handle special search terms for each active term
activeSearchTerms.forEach(term => {
handleSpecialSearchTerm(term, 'remove');
});
setActiveSearchTerms([]);
// Use the wrapped setSearchTerm
setSearchTerm('');
}, [activeSearchTerms, handleSpecialSearchTerm, setSearchTerm]);
// Context value
const searchContextValue = {
searchTerm,
setSearchTerm,
activeSearchTerms,
addSearchTerm,
removeSearchTerm,
clearSearchTerms,
isSearching,
setIsSearching,
handleSpecialSearchTerm,
};
return (
<SearchContext.Provider value={searchContextValue}>
{children}
</SearchContext.Provider>
);
};
-212
View File
@@ -1,212 +0,0 @@
import React, { createContext, useState, useContext, useEffect, useMemo } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
// Storage key
const STORAGE_KEY_DARK_MODE = 'app_dark_mode';
// Create the context
export const ThemeContext = createContext({
darkMode: false,
toggleDarkMode: () => {},
});
// Custom hook to use the theme context
export const useThemeContext = () => useContext(ThemeContext);
// Theme provider component
export const AppThemeProvider = ({ children }) => {
// Initialize dark mode from localStorage or system preference
const [darkMode, setDarkMode] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_DARK_MODE);
if (saved === null) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return JSON.parse(saved) === true;
} catch (e) {
console.error('Error loading dark mode preference:', e);
return false;
}
});
// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
// Only update if the user hasn't set a preference
if (localStorage.getItem(STORAGE_KEY_DARK_MODE) === null) {
setDarkMode(e.matches);
}
};
// Add event listener
mediaQuery.addEventListener('change', handleChange);
// Cleanup
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
// Save dark mode preference when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY_DARK_MODE, JSON.stringify(darkMode));
}, [darkMode]);
// Toggle dark mode function
const toggleDarkMode = () => {
setDarkMode(prev => !prev);
};
// Context value
const themeContextValue = useMemo(() => ({
darkMode,
toggleDarkMode,
}), [darkMode]);
// Create MUI theme based on dark mode state
const theme = useMemo(() => createTheme({
palette: {
mode: darkMode ? 'dark' : 'light',
primary: {
main: '#3a7bd5',
light: '#5e9cf5',
dark: '#2c5ea3',
contrastText: '#ffffff',
},
secondary: {
main: darkMode ? '#bb86fc' : '#00b8d4',
light: darkMode ? '#d7b8fc' : '#62ebff',
dark: darkMode ? '#9d4edd' : '#0088a3',
contrastText: '#ffffff',
},
background: {
default: darkMode ? '#121212' : '#f8f9fa',
paper: darkMode ? '#1e1e1e' : '#ffffff',
},
success: {
main: '#4caf50',
light: '#80e27e',
dark: '#087f23',
},
warning: {
main: '#ff9800',
light: '#ffc947',
dark: '#c66900',
},
error: {
main: '#f44336',
light: '#ff7961',
dark: '#ba000d',
},
info: {
main: '#2196f3',
light: '#64b5f6',
dark: '#0069c0',
},
text: {
primary: darkMode ? '#e0e0e0' : '#212121',
secondary: darkMode ? '#a0a0a0' : '#757575',
disabled: darkMode ? '#6c6c6c' : '#9e9e9e',
},
divider: darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)',
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h6: {
fontWeight: 500,
letterSpacing: '0.0075em',
},
body1: {
fontSize: '0.9rem',
},
body2: {
fontSize: '0.8rem',
},
},
shape: {
borderRadius: 8,
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: darkMode
? '0 2px 10px rgba(0, 0, 0, 0.5)'
: '0 2px 10px rgba(0, 0, 0, 0.05)',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
boxShadow: darkMode
? '0 2px 10px rgba(0, 0, 0, 0.2)'
: '0 2px 10px rgba(0, 0, 0, 0.05)',
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
padding: '12px 16px',
borderBottomColor: darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(224, 224, 224, 1)',
},
head: {
fontWeight: 600,
color: darkMode ? '#e0e0e0' : '#424242',
userSelect: 'none',
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
'&:last-child td, &:last-child th': {
border: 0,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 500,
},
},
},
MuiCssBaseline: {
styleOverrides: {
body: {
transition: 'background-color 0.3s ease, color 0.3s ease',
scrollbarWidth: 'thin',
scrollbarColor: darkMode ? '#3a3a3a #1e1e1e' : '#bbb #f1f1f1',
userSelect: 'none',
'&::-webkit-scrollbar': {
width: '8px',
height: '8px',
},
'&::-webkit-scrollbar-track': {
background: darkMode ? '#1e1e1e' : '#f1f1f1',
},
'&::-webkit-scrollbar-thumb': {
background: darkMode ? '#3a3a3a' : '#bbb',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: darkMode ? '#555' : '#999',
},
},
},
},
},
}), [darkMode]);
return (
<ThemeContext.Provider value={themeContextValue}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeContext.Provider>
);
};
@@ -1,93 +0,0 @@
import React, { createContext, useState, useContext, useEffect, useMemo } from 'react';
import { useThemeContext } from './ThemeContext';
// Storage keys
const STORAGE_KEY_COMPACT_MODE = 'app_compact_mode';
const STORAGE_KEY_SHOW_ONLY_RUNNING = 'app_show_only_running';
// Create the context
export const UserSettingsContext = createContext({
compactMode: true,
toggleCompactMode: () => {},
getTableCellPadding: () => {},
showOnlyRunning: false,
toggleShowOnlyRunning: () => {},
});
// Custom hook to use the user settings context
export const useUserSettings = () => useContext(UserSettingsContext);
// User Settings provider component
export const UserSettingsProvider = ({ children }) => {
const { darkMode, toggleDarkMode } = useThemeContext();
// Initialize compact mode - always true (no longer checking localStorage)
const [compactMode, setCompactMode] = useState(true);
// Initialize showOnlyRunning state from localStorage
const [showOnlyRunning, setShowOnlyRunning] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_SHOW_ONLY_RUNNING);
return saved === 'true';
} catch (e) {
console.error('Error loading show only running preference:', e);
return false;
}
});
// Save compact mode preference when it changes - keeping for backward compatibility
useEffect(() => {
localStorage.setItem(STORAGE_KEY_COMPACT_MODE, 'true');
}, []);
// Save showOnlyRunning preference when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SHOW_ONLY_RUNNING, showOnlyRunning);
// Dispatch a custom event that NetworkDisplay can listen for
if (window) {
const event = new CustomEvent('showOnlyRunningChange', {
detail: { showOnlyRunning }
});
window.dispatchEvent(event);
}
}, [showOnlyRunning]);
// Toggle compact mode function - keeping for backward compatibility
const toggleCompactMode = () => {
// No-op since we always want compact mode
console.debug('Compact mode toggle attempted but compact mode is always enabled');
};
// Toggle showOnlyRunning function
const toggleShowOnlyRunning = () => {
setShowOnlyRunning(prev => !prev);
};
// Helper function to get the table cell padding based on the compact mode
const getTableCellPadding = (isNarrowColumn = false) => {
if (isNarrowColumn) {
return '0px 8px'; // Fixed narrow columns always have minimal padding
}
// Always use compact mode padding
return '4px 8px';
};
// Context value
const settingsContextValue = useMemo(() => ({
compactMode: true, // Always true
toggleCompactMode,
getTableCellPadding,
darkMode,
toggleDarkMode,
showOnlyRunning,
toggleShowOnlyRunning,
}), [darkMode, toggleDarkMode, showOnlyRunning]);
return (
<UserSettingsContext.Provider value={settingsContextValue}>
{children}
</UserSettingsContext.Provider>
);
};
-99
View File
@@ -1,99 +0,0 @@
import { useMemo } from 'react';
/**
* Custom hook to transform metrics data from the useSocket hook into the format expected by the components
* Uses space-efficient data representations for important metrics
* @param {Array} metricsData - The metrics data from the useSocket hook
* @returns {Object} The transformed metrics data
*/
const useFormattedMetrics = (metricsData) => {
return useMemo(() => {
// Initialize the structure
const result = {
cpu: {},
memory: {},
disk: {},
network: {}
};
// If no metrics data, return empty structure
if (!metricsData || !Array.isArray(metricsData) || metricsData.length === 0) {
return result;
}
// Process each metric in the array
metricsData.forEach(metric => {
if (!metric || !metric.guestId) {
return;
}
const guestId = metric.guestId;
// Extract metrics from the metrics object structure
const metricData = metric.metrics || {};
// CPU metrics - store as integer percentage (0-100)
if (typeof metricData.cpu === 'number') {
const cpuValue = metricData.cpu;
// Convert to integer percentage if needed (assuming it's already a percentage)
const cpuPercentage = Math.round(cpuValue);
result.cpu[guestId] = {
usage: cpuPercentage
};
}
// Memory metrics - store values in appropriate units
const memoryData = metricData.memory || {};
if (memoryData) {
// Store percentage as integer (0-100)
const memoryPercentage = memoryData.usedPercentage !== undefined
? Math.round(memoryData.usedPercentage)
: (memoryData.total && memoryData.used
? Math.round((memoryData.used / memoryData.total) * 100)
: 0);
result.memory[guestId] = {
used: memoryData.used,
total: memoryData.total,
usagePercent: memoryPercentage
};
}
// Disk metrics - store values in appropriate units
const diskData = metricData.disk || {};
if (diskData) {
// Store percentage as integer (0-100)
const diskPercentage = diskData.usedPercentage !== undefined
? Math.round(diskData.usedPercentage)
: (diskData.total && diskData.used
? Math.round((diskData.used / diskData.total) * 100)
: 0);
result.disk[guestId] = {
used: diskData.used,
total: diskData.total,
usagePercent: diskPercentage
};
}
// Network metrics - round to appropriate precision
const networkData = metricData.network || {};
if (networkData) {
// Round network rates to integers if they're small, or to 2 decimal places if large
const inRate = networkData.inRate || 0;
const outRate = networkData.outRate || 0;
result.network[guestId] = {
// Use appropriate precision based on value size
inRate: inRate < 1000 ? Math.round(inRate) : inRate,
outRate: outRate < 1000 ? Math.round(outRate) : outRate
};
}
});
return result;
}, [metricsData]);
};
export default useFormattedMetrics;
-279
View File
@@ -1,279 +0,0 @@
import { useState, useEffect } from 'react';
/**
* Custom hook to provide mock metrics data for testing
* @returns {Object} The mock metrics data
*/
const useMockMetrics = (guestData) => {
const [mockMetrics, setMockMetrics] = useState({
cpu: {},
memory: {},
disk: {},
network: {}
});
// Keep track of trends for more realistic changes
const [trends, setTrends] = useState({});
// Generate initial mock metrics
useEffect(() => {
if (!guestData || guestData.length === 0) return;
const newMockMetrics = {
cpu: {},
memory: {},
disk: {},
network: {}
};
const initialTrends = {};
guestData.forEach(guest => {
// Generate random CPU usage between 5% and 80%
newMockMetrics.cpu[guest.id] = {
usage: Math.floor(Math.random() * 75) + 5
};
// Generate random memory usage between 10% and 90%
const memoryPercent = Math.floor(Math.random() * 80) + 10;
newMockMetrics.memory[guest.id] = {
used: Math.floor(Math.random() * 8 * 1024 * 1024 * 1024) + 512 * 1024 * 1024, // 512MB to 8.5GB
total: 16 * 1024 * 1024 * 1024, // 16GB
usagePercent: memoryPercent
};
// Generate random disk usage between 20% and 95%
const diskPercent = Math.floor(Math.random() * 75) + 20;
newMockMetrics.disk[guest.id] = {
used: Math.floor(Math.random() * 900 * 1024 * 1024 * 1024) + 100 * 1024 * 1024 * 1024, // 100GB to 1TB
total: 1024 * 1024 * 1024 * 1024, // 1TB
usagePercent: diskPercent
};
// Generate random network rates
newMockMetrics.network[guest.id] = {
inRate: Math.floor(Math.random() * 10 * 1024 * 1024) + 1024, // 1KB/s to 10MB/s
outRate: Math.floor(Math.random() * 5 * 1024 * 1024) + 1024 // 1KB/s to 5MB/s
};
// Initialize trends
initialTrends[guest.id] = {
cpu: Math.random() > 0.5 ? 1 : -1, // Random initial trend direction
memory: Math.random() > 0.5 ? 1 : -1, // Random initial trend direction
disk: 1, // Disk typically grows
netIn: 0, // No initial trend
netOut: 0, // No initial trend
spikeCooldown: 0, // No initial cooldown
loadLevel: Math.floor(Math.random() * 3) // 0=light, 1=medium, 2=heavy
};
});
setMockMetrics(newMockMetrics);
setTrends(initialTrends);
}, [guestData]);
// Update mock metrics periodically with more dynamic changes
useEffect(() => {
if (!guestData || guestData.length === 0) return;
const interval = setInterval(() => {
setMockMetrics(prev => {
const updated = { ...prev };
setTrends(prevTrends => {
const updatedTrends = { ...prevTrends };
guestData.forEach(guest => {
if (!updatedTrends[guest.id]) {
// Initialize trend data for new guests
updatedTrends[guest.id] = {
cpu: Math.random() > 0.5 ? 1 : -1,
memory: Math.random() > 0.5 ? 1 : -1,
disk: 1,
netIn: 0,
netOut: 0,
spikeCooldown: 0,
loadLevel: Math.floor(Math.random() * 3)
};
}
const trend = updatedTrends[guest.id];
// Occasionally change trend direction
if (Math.random() < 0.1) {
trend.cpu *= -1;
}
if (Math.random() < 0.05) {
trend.memory *= -1;
}
// Occasionally change load level
if (Math.random() < 0.05) {
trend.loadLevel = Math.floor(Math.random() * 3);
}
// Decrease spike cooldown
if (trend.spikeCooldown > 0) {
trend.spikeCooldown--;
}
// Handle CPU updates with trend-based changes
const cpuConfig = {
changeRange: trend.loadLevel === 0 ? 2 : trend.loadLevel === 1 ? 5 : 10,
minValue: trend.loadLevel === 0 ? 5 : trend.loadLevel === 1 ? 20 : 40,
maxValue: trend.loadLevel === 0 ? 30 : trend.loadLevel === 1 ? 60 : 95,
spikeChance: trend.loadLevel === 0 ? 0.02 : trend.loadLevel === 1 ? 0.05 : 0.1
};
// Update CPU
if (updated.cpu[guest.id]) {
let newCpuUsage = updated.cpu[guest.id].usage;
// Check for spikes
if (trend.spikeCooldown === 0 && Math.random() < cpuConfig.spikeChance) {
// Create a spike
newCpuUsage = Math.min(95, newCpuUsage + Math.random() * 40 + 20);
trend.spikeCooldown = Math.floor(Math.random() * 5) + 3; // 3-7 intervals cooldown
} else {
// Normal fluctuation
const change = (Math.random() * cpuConfig.changeRange * 2 - cpuConfig.changeRange) +
(trend.cpu * cpuConfig.changeRange * 0.5);
newCpuUsage = Math.max(cpuConfig.minValue,
Math.min(cpuConfig.maxValue, newCpuUsage + change));
}
updated.cpu[guest.id].usage = newCpuUsage;
}
// Update Memory with trend-based changes
if (updated.memory[guest.id]) {
const memConfig = {
changePercent: 0.03, // Maximum 3% change per update
minPercent: 10,
maxPercent: 90
};
// Memory often follows CPU with a delay
const cpuInfluence = trend.cpu > 0 ? 1 : -0.5;
const change = ((Math.random() * memConfig.changePercent * 2) - memConfig.changePercent +
(trend.memory * memConfig.changePercent * 0.7) +
(cpuInfluence * memConfig.changePercent * 0.3)) * 100;
const newPercent = Math.max(memConfig.minPercent,
Math.min(memConfig.maxPercent,
updated.memory[guest.id].usagePercent + change));
const newUsed = updated.memory[guest.id].total * (newPercent / 100);
updated.memory[guest.id].used = newUsed;
updated.memory[guest.id].usagePercent = newPercent;
}
// Update Disk with slow growth and occasional cleanups
if (updated.disk[guest.id]) {
// Disk usually grows slowly
let diskChange = (Math.random() * 0.5); // 0-0.5% growth per update
// Occasional disk cleanup (5% chance)
if (Math.random() < 0.05) {
diskChange = -1 * (Math.random() * 3 + 1); // 1-4% reduction
}
const newPercent = Math.max(20, Math.min(95, updated.disk[guest.id].usagePercent + diskChange));
const newUsed = updated.disk[guest.id].total * (newPercent / 100);
updated.disk[guest.id].used = newUsed;
updated.disk[guest.id].usagePercent = newPercent;
}
// Update Network with burst patterns
if (updated.network[guest.id]) {
// Define realistic baseline values (in bytes/sec)
const baselineInRate = 20 * 1024; // 20 KB/s baseline
const baselineOutRate = 10 * 1024; // 10 KB/s baseline
// Maximum sustainable values (in bytes/sec)
const maxSustainedInRate = 300 * 1024; // 300 KB/s max sustained
const maxSustainedOutRate = 150 * 1024; // 150 KB/s max sustained
// Current values
const currentInRate = updated.network[guest.id].inRate;
const currentOutRate = updated.network[guest.id].outRate;
// Force strong regression to baseline over time
// Reset to baseline every ~30 seconds (1 in 20 chance per 1.5 sec interval)
const needsReset = Math.random() < 0.05;
// Determine if we should generate a burst
// Reduce burst frequency when rates are already high
const ratesAreHigh = currentInRate > 100 * 1024 || currentOutRate > 50 * 1024;
const burstProbability = ratesAreHigh ? 0.05 : 0.1; // Reduce burst chance when already high
const burstMode = Math.random() < burstProbability;
// Calculate new rates based on current state
let newInRate, newOutRate;
if (needsReset) {
// Periodic reset to baseline to prevent long-term growth
newInRate = baselineInRate + (Math.random() * 10 * 1024); // baseline + small randomness
newOutRate = baselineOutRate + (Math.random() * 5 * 1024); // baseline + small randomness
} else if (burstMode) {
// Generate a traffic burst
const burstInSize = Math.random() * 150 * 1024 + 50 * 1024; // 50-200KB/s increase
const burstOutSize = Math.random() * 100 * 1024 + 30 * 1024; // 30-130KB/s increase
// Apply burst, but cap at max values
newInRate = Math.min(maxSustainedInRate, currentInRate + burstInSize);
newOutRate = Math.min(maxSustainedOutRate, currentOutRate + burstOutSize);
} else {
// Normal regression toward baseline - stronger the further from baseline
const inRateDistanceFromBaseline = Math.max(0, currentInRate - baselineInRate);
const outRateDistanceFromBaseline = Math.max(0, currentOutRate - baselineOutRate);
// Decay rate increases with distance (50-80% decay)
const inDecayRate = 0.5 + (inRateDistanceFromBaseline / (maxSustainedInRate * 2));
const outDecayRate = 0.5 + (outRateDistanceFromBaseline / (maxSustainedOutRate * 2));
// Calculate decay amount (strong regression to baseline)
const inDecayAmount = inRateDistanceFromBaseline * Math.min(0.8, inDecayRate);
const outDecayAmount = outRateDistanceFromBaseline * Math.min(0.8, outDecayRate);
// Add small random fluctuation (-10KB to +5KB)
const randomFluctuation = (Math.random() * 15 - 10) * 1024;
// Apply decay with fluctuation, ensuring we don't go below baseline
newInRate = Math.max(
baselineInRate,
currentInRate - inDecayAmount + randomFluctuation
);
newOutRate = Math.max(
baselineOutRate,
currentOutRate - outDecayAmount + (randomFluctuation / 2)
);
}
// Final safety caps
newInRate = Math.min(maxSustainedInRate, Math.max(baselineInRate / 2, newInRate));
newOutRate = Math.min(maxSustainedOutRate, Math.max(baselineOutRate / 2, newOutRate));
// Update the network metrics
updated.network[guest.id].inRate = newInRate;
updated.network[guest.id].outRate = newOutRate;
}
});
return updatedTrends;
});
return updated;
});
}, 1500); // Update more frequently (every 1.5 seconds instead of 5 seconds)
return () => clearInterval(interval);
}, [guestData]);
return mockMetrics;
};
export default useMockMetrics;
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
/* Column dragging styles */
.column-dragging {
cursor: grabbing !important;
}
.column-dragging * {
cursor: grabbing !important;
}
/* Table header styles for dragging */
.MuiTableCell-head {
position: relative;
cursor: grab;
}
/* Drop highlight effect */
.drop-highlight-before {
box-shadow: inset 3px 0 0 var(--mui-palette-primary-main) !important;
}
.drop-highlight-after {
box-shadow: inset -3px 0 0 var(--mui-palette-primary-main) !important;
}
/* Transition for all table cells */
.MuiTableCell-head {
transition: background-color 0.2s, box-shadow 0.2s, transform 0.1s, opacity 0.2s !important;
}
-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);
-374
View File
@@ -1,374 +0,0 @@
/**
* Exhaustive Search Test Suite
*
* This test suite verifies ALL possible search patterns, combinations, and edge cases.
* It uses both mock data and real-world data structures to ensure accuracy.
*/
const { getSortedAndFilteredData } = require('./networkUtils');
// Create a more extensive mock data set with varied properties
const generateExhaustiveTestData = () => {
// Base test data similar to the real application
const guests = [
// PRIMARY GUESTS
{
id: '101',
name: 'web-server', // Plain name
type: 'qemu',
status: 'running',
node: 'pve-prod-01',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['web', 'production']
},
{
id: '102',
name: 'database-primary', // Has "primary" in name
type: 'qemu',
status: 'running',
node: 'pve-prod-01',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['db', 'primary', 'production']
},
{
id: '103',
name: 'sprint-server', // Has "pri" as substring
type: 'qemu',
status: 'paused',
node: 'pve-prod-01',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['sprint', 'production']
},
// SECONDARY GUESTS
{
id: '201',
name: 'web-secondary',
type: 'qemu',
status: 'running',
node: 'pve-prod-02',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['web', 'secondary']
},
{
id: '202',
name: 'sec-database', // Has "sec" at start of name
type: 'lxc',
status: 'stopped',
node: 'pve-prod-02',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['db', 'backup']
},
{
id: '203',
name: 'prism-backup', // Has "pri" as substring
type: 'lxc',
status: 'running',
node: 'pve-prod-02',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['backup']
},
// NON-SHARED GUESTS
{
id: '301',
name: 'standalone-pri-app', // Has "pri" in name but is NOT primary
type: 'qemu',
status: 'running',
node: 'pve-dev-01',
shared: false,
tags: ['dev', 'private']
},
{
id: '302',
name: 'security-container', // Has "sec" as substring but is NOT secondary
type: 'lxc',
status: 'stopped',
node: 'pve-dev-01',
shared: false,
tags: ['security', 'dev']
},
// EDGE CASES
{
id: '401',
name: 'p', // Single-letter name matching a role search
type: 'qemu',
status: 'running',
node: 'pve-edge-01',
shared: true,
primaryNode: 'pve-edge-01',
tags: ['test']
},
{
id: '402',
name: 's', // Single-letter name matching a role search
type: 'qemu',
status: 'running',
node: 'pve-edge-01',
shared: true,
primaryNode: 'pve-prod-01',
tags: ['test']
}
];
// Add optional metrics data
const metricsData = {
cpu: {},
memory: {},
disk: {},
network: {}
};
guests.forEach(guest => {
const id = guest.id;
metricsData.cpu[id] = { usage: Math.random() * 100 };
metricsData.memory[id] = { usagePercent: Math.random() * 100 };
metricsData.disk[id] = { usagePercent: Math.random() * 100 };
metricsData.network[id] = { inRate: Math.random() * 10000000, outRate: Math.random() * 10000000 };
});
// Node data for name resolution
const nodeData = [
{ id: 'pve-prod-01', name: 'prod-cluster-1' },
{ id: 'pve-prod-02', name: 'prod-cluster-2' },
{ id: 'pve-dev-01', name: 'dev-cluster' },
{ id: 'pve-edge-01', name: 'edge-node' }
];
return { guests, metricsData, nodeData };
};
// Function to run all exhaustive tests
export function runExhaustiveTests() {
console.log('===== EXHAUSTIVE SEARCH TEST SUITE =====');
console.log('Testing all possible search patterns and edge cases');
const { guests, metricsData, nodeData } = generateExhaustiveTestData();
// Collection of all test cases
const testCases = [
// 1. ROLE BASED SEARCHES - STANDALONE TERMS
// Each of these tests a specific role search term
{ category: 'ROLE STANDALONE', term: 'role', description: 'All shared guests',
expectIdsContaining: ['101', '102', '103', '201', '202', '203', '401', '402'] },
{ category: 'ROLE STANDALONE', term: 'shared', description: 'Alternative for all shared guests',
expectIdsContaining: ['101', '102', '103', '201', '202', '203', '401', '402'] },
{ category: 'ROLE STANDALONE', term: 'primary', description: 'Finds primary guests',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE STANDALONE', term: 'pri', description: 'Short form of primary',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE STANDALONE', term: 'p', description: 'Shortest form of primary',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE STANDALONE', term: 'secondary', description: 'Finds secondary guests',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'ROLE STANDALONE', term: 'sec', description: 'Short form of secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'ROLE STANDALONE', term: 's', description: 'Shortest form of secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
// 2. ROLE COLUMN SEARCHES
{ category: 'ROLE COLUMN', term: 'role:primary', description: 'Column search for primary',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE COLUMN', term: 'role:pri', description: 'Column search with short primary',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE COLUMN', term: 'role:p', description: 'Column search with shortest primary',
expectIdsContaining: ['101', '102', '103', '401'] },
{ category: 'ROLE COLUMN', term: 'role:secondary', description: 'Column search for secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'ROLE COLUMN', term: 'role:sec', description: 'Column search with short secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'ROLE COLUMN', term: 'role:s', description: 'Column search with shortest secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'ROLE COLUMN', term: 'role:none', description: 'Column search for non-shared',
expectIdsContaining: ['301', '302'] },
{ category: 'ROLE COLUMN', term: 'role:-', description: 'Column search for non-shared (dash)',
expectIdsContaining: ['301', '302'] },
// 3. TEXT SEARCHES WITH ROLE COMPONENTS
{ category: 'TEXT MATCH', term: 'primary-', description: 'Has primary in name but with a suffix',
expectIdsContaining: ['102'] },
{ category: 'TEXT MATCH', term: 'sprint', description: 'Has pri in middle of name but not as a word',
expectIdsContaining: ['103'] },
{ category: 'TEXT MATCH', term: 'secondary', description: 'Has secondary in name and/or is secondary',
expectIdsContaining: ['201', '202', '203', '402'] },
{ category: 'TEXT MATCH', term: 'prism', description: 'Has pri as substring',
expectIdsContaining: ['203'] },
// 4. PARTIAL SEARCHES AND EDGE CASES
// The 'p' search should match shared guests with primary role AND match 'p' in text
{ category: 'SINGLE CHAR', term: 'p', description: 'Single letter p',
expectIdsContaining: ['101', '102', '103', '201', '202', '203', '301', '302', '401', '402'] },
// Same for 's'
{ category: 'SINGLE CHAR', term: 's', description: 'Single letter s',
expectIdsContaining: ['101', '102', '103', '201', '202', '203', '301', '302', '401', '402'] },
// Test pve prefix in node names
{ category: 'SINGLE CHAR', term: 'pve', description: 'Text in all node IDs',
expectIdsContaining: ['101', '102', '103', '201', '202', '203', '301', '302', '401', '402'] },
// Test a mix of properties and values
{ category: 'TYPE SEARCH', term: 'qemu', description: 'VM type search',
expectIdsContaining: ['101', '102', '103', '201', '301', '401', '402'] },
{ category: 'TYPE SEARCH', term: 'vm', description: 'VM alternate search',
expectIdsContaining: ['101', '102', '103', '201', '301', '401', '402'] },
{ category: 'TYPE SEARCH', term: 'lxc', description: 'Container type search',
expectIdsContaining: ['202', '203', '302'] },
// 5. COMBINED SEARCHES (AND LOGIC)
{ category: 'COMBINED', term: 'primary running', description: 'Primary AND running status',
expectIdsContaining: ['101', '102', '401'] },
{ category: 'COMBINED', term: 'sec stopped', description: 'Secondary AND stopped status',
expectIdsContaining: ['202'] },
// 6. NEGATIVE TESTS - these should NOT match
{ category: 'NEGATIVE', term: 'nonexistent', description: 'Term that appears nowhere',
expectIdsContaining: [] }
];
// Run the tests
console.log(`Running ${testCases.length} comprehensive search test cases\n`);
let passCount = 0;
let failCount = 0;
// Keep track of failures by category
const failures = {};
testCases.forEach((testCase, index) => {
console.log(`[${index + 1}/${testCases.length}] Testing "${testCase.term}" - ${testCase.description}`);
// Run the search
const filteredData = getSortedAndFilteredData(
guests,
{ key: 'name', direction: 'asc' }, // Default sort
{}, // No filters
null, // Show all statuses
[testCase.term], // Single search term as an array
'', // No active search term
metricsData,
'all', // Show all guest types
nodeData
);
// Extract result IDs
const resultIds = filteredData.map(guest => guest.id);
// Validate expected IDs are included
const expectedIds = testCase.expectIdsContaining;
const missingIds = expectedIds.filter(id => !resultIds.includes(id));
const unexpectedIds = resultIds.filter(id => !expectedIds.includes(id));
const passed = missingIds.length === 0 &&
(unexpectedIds.length === 0 || expectedIds.length === 0 && resultIds.length === 0);
if (passed) {
console.log(` ✅ PASSED - Found ${resultIds.length} guests as expected`);
passCount++;
} else {
console.log(` ❌ FAILED`);
if (missingIds.length > 0) {
console.log(` Missing IDs: ${missingIds.join(', ')}`);
}
if (unexpectedIds.length > 0) {
console.log(` Unexpected IDs: ${unexpectedIds.join(', ')}`);
}
// Add to failures by category
const category = testCase.category;
if (!failures[category]) {
failures[category] = [];
}
failures[category].push({
term: testCase.term,
description: testCase.description,
expected: expectedIds,
actual: resultIds,
missing: missingIds,
unexpected: unexpectedIds
});
failCount++;
}
console.log(''); // Empty line for readability
});
// Print summary
console.log('===== TEST SUMMARY =====');
console.log(`Total tests: ${testCases.length}`);
console.log(`Passed: ${passCount} (${(passCount/testCases.length*100).toFixed(1)}%)`);
console.log(`Failed: ${failCount} (${(failCount/testCases.length*100).toFixed(1)}%)`);
// Print failures by category
if (failCount > 0) {
console.log('\n===== FAILURES BY CATEGORY =====');
Object.keys(failures).forEach(category => {
console.log(`\n${category} - ${failures[category].length} failures:`);
failures[category].forEach(failure => {
console.log(` "${failure.term}" - ${failure.description}`);
console.log(` Expected: ${failure.expected.join(', ')}`);
console.log(` Actual: ${failure.actual.join(', ')}`);
});
});
// Provide advice for fixing issues
console.log('\n===== TROUBLESHOOTING =====');
// Check specifically for 'pri' issues
if (failures['ROLE STANDALONE']?.some(f => f.term === 'pri')) {
console.log('\nIssue detected with "pri" searches:');
console.log('1. Check if "pri" is being treated as a special case rather than a role indicator');
console.log('2. Ensure proper handling of "pri" at the beginning of the search logic');
console.log('3. Make sure word boundaries are properly enforced for "pri" matches');
}
// Check for single character issues
if (Object.keys(failures).includes('SINGLE CHAR')) {
console.log('\nIssue detected with single character searches:');
console.log('1. Single character searches should perform a full text search across all fields');
console.log('2. Check that single character logic runs BEFORE role-specific logic');
}
}
return {
totalTests: testCases.length,
passed: passCount,
failed: failCount,
failures
};
}
// Run the tests if executed directly
if (typeof require !== 'undefined' && require.main === module) {
runExhaustiveTests();
}
module.exports = { runExhaustiveTests };
-57
View File
@@ -1,57 +0,0 @@
# Search Implementation
## Formal Specification Based Search
This search implementation follows a formal specification approach to ensure robustness, consistency, and correctness. The search logic has been designed to be systematic and predictable, without relying on special case handling.
### Core Design Principles
1. **Taxonomic Structure**: The search logic follows a clear taxonomy of search operations:
- Column-specific searches (column:value)
- Metric comparisons with operators (>, <, =)
- Standard role terminology (primary, secondary, shared)
- Type and status keywords (vm, ct, running, stopped)
- Single character searches (for any text field)
- Numeric ID exact matches
- General text search across all fields
2. **No Special Case Handling**: No "priority" or special handling for specific terms like 'pri'. All terms are processed through the same clean pipeline.
3. **Proper Single Character Handling**: Single character searches (like 'p', 's') match any text containing those characters, ensuring consistent behavior.
4. **Predictable Behavior**: The search execution follows a clear, deterministic path without shortcuts or bypasses.
### Search Logic Flow
1. **Column-specific searches**: First check if the term contains a colon (e.g., `role:primary`, `status:running`).
2. **Metric comparisons**: Check for resource expressions with operators (e.g., `cpu>50`, `memory<80`).
3. **Standard role terminology**: Match exact role terms like 'primary', 'pri', 'secondary', 'sec'.
4. **Type and status keywords**: Handle standard VM/container type and status terms.
5. **Single character searches**: For single character terms, search across all text fields.
6. **Numeric ID searches**: Handle IDs with special numeric matching rules.
7. **Full text search**: As a fallback, search all text fields.
### Execution Instructions
Run the test suite to verify search functionality works as expected:
```bash
node frontend/src/utils/runSearchTests.js
```
The test suite validates comprehensive search patterns:
- Basic text searches
- Role-specific searches ('pri', 'sec', 'primary', 'secondary')
- Column-based searches ('role:pri', 'type:vm', etc.)
- Single character searches ('p', 's', 'v', etc.)
- Combined search terms with AND logic
### Search Term Guidelines
For consistent results, use standard search terms:
- Role searches: 'primary', 'pri', 'secondary', 'sec'
- Type searches: 'vm', 'ct', 'container'
- Status searches: 'running', 'stopped', 'paused', 'suspended'
- Column-specific: 'role:primary', 'status:running', etc.
- Metrics: 'cpu>80', 'memory<50', etc.
- Single characters: Any letter for a text-based search
-79
View File
@@ -1,79 +0,0 @@
/**
* Comprehensive Search Verification Script
*
* This script runs a battery of tests to verify that ALL search patterns work correctly.
* It combines both standard tests and special test cases for edge conditions.
*/
import { runSearchTests } from './searchTests.js';
console.log('===============================================================');
console.log('= COMPREHENSIVE SEARCH VERIFICATION =');
console.log('===============================================================');
console.log('This script verifies ALL search patterns work correctly, including:');
console.log('- Basic text searches');
console.log('- Role-specific searches (pri, sec, primary, secondary)');
console.log('- Column-based searches (role:pri, type:vm, etc.)');
console.log('- Single character searches (p, s, v, etc.)');
console.log('- Edge cases and potentially problematic patterns');
console.log('\nRunning standard search test suite...');
// Execute all standard tests
const results = runSearchTests();
// Output summary
console.log('\n===============================================================');
console.log('= VERIFICATION RESULTS =');
console.log('===============================================================');
if (results.general.failed === 0 && results.role.filter(r => !r.passed).length === 0) {
console.log('✅ ALL TESTS PASSED!');
console.log(` - ${results.general.passed} general tests passed`);
console.log(` - ${results.role.filter(r => r.passed).length} role-specific tests passed`);
console.log('\nThe search functionality is working correctly for all patterns.');
console.log('Key validations:');
console.log(' - "pri" correctly returns primary guests');
console.log(' - "sec" correctly returns secondary guests');
console.log(' - Single-character searches work properly');
console.log(' - Combined searches apply AND logic correctly');
} else {
console.log('❌ SOME TESTS FAILED!');
console.log(` - ${results.general.failed} general tests failed`);
console.log(` - ${results.role.filter(r => !r.passed).length} role-specific tests failed`);
// Show detailed failures
console.log('\nFailed tests:');
if (results.general.failed > 0) {
results.general.details
.filter(detail => !detail.passed)
.forEach(detail => {
console.log(` - ${detail.name}: ${detail.description}`);
console.log(` Expected: ${JSON.stringify(detail.expectedIds)}`);
console.log(` Actual: ${JSON.stringify(detail.actualIds)}`);
});
}
const failedRoleTests = results.role.filter(r => !r.passed);
if (failedRoleTests.length > 0) {
console.log('\nFailed role-specific tests:');
failedRoleTests.forEach(r => {
console.log(` - "${r.term}": Expected ${JSON.stringify(r.expectedIds)}, got ${JSON.stringify(r.resultIds)}`);
});
}
}
// Execution instructions
console.log('\n===============================================================');
console.log('= HOW TO USE THIS VERIFICATION TOOL =');
console.log('===============================================================');
console.log('Run this script after making any changes to the search functionality:');
console.log(' node frontend/src/utils/VerifyAllSearches.js');
console.log('\nIf you add new search capabilities, update searchTests.js to include tests');
console.log('for the new functionality.');
// Exit with appropriate code
if (results.general.failed === 0 && results.role.filter(r => !r.passed).length === 0) {
process.exit(0);
} else {
process.exit(1);
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Basic Search Test for Role Column
*
* This script tests if the search functionality is correctly working with role-based searches.
* It focuses on testing the most important search terms to ensure core functionality works.
*/
// Mock guest data
const mockGuests = [
{ id: '101', name: 'web-server', type: 'qemu', status: 'running', node: 'pve1', shared: true, primaryNode: 'pve1', hastate: 'started' },
{ id: '102', name: 'database', type: 'qemu', status: 'running', node: 'pve1', shared: true, primaryNode: 'pve2', hastate: 'stopped' },
{ id: '103', name: 'app-container', type: 'lxc', status: 'running', node: 'pve2', shared: true, primaryNode: 'pve2', hastate: 'started' },
{ id: '104', name: 'backup-storage', type: 'qemu', status: 'stopped', node: 'pve2', shared: false, hastate: undefined },
];
// Mock node data
const mockNodeData = [
{ id: 'pve1', name: 'pve-prod-01' },
{ id: 'pve2', name: 'pve-prod-02' },
];
// Import the search utility - in real implementation this would be from networkUtils
function mockMatchesTerm(guest, term, nodeData) {
term = term.toLowerCase();
// Test if this is a role-specific search
if (term.startsWith('role:')) {
const roleValue = term.split(':')[1];
if (!roleValue) return true; // Empty value matches all
// Check for primary/secondary role
if (roleValue === 'primary' || roleValue === 'pri') {
return guest.shared && guest.primaryNode === guest.node;
}
if (roleValue === 'secondary' || roleValue === 'sec') {
return guest.shared && guest.primaryNode !== guest.node;
}
if (roleValue === '-' || roleValue === 'none') {
return !guest.shared;
}
// Default - check if the term appears in relevant fields
return false;
}
// For direct role keyword searches
if (term === 'primary' || term === 'pri') {
return guest.shared && guest.primaryNode === guest.node;
}
if (term === 'secondary' || term === 'sec') {
return guest.shared && guest.primaryNode !== guest.node;
}
// For combined searches (example: "primary lxc")
if (term.includes(' ')) {
const parts = term.split(' ');
return parts.some(part => mockMatchesTerm(guest, part, nodeData));
}
// Test for type matches
if (term === 'vm' || term === 'qemu') {
return guest.type === 'qemu';
}
if (term === 'ct' || term === 'lxc' || term === 'container') {
return guest.type === 'lxc';
}
// Test for status matches
if (term === 'running') {
return guest.status === 'running';
}
if (term === 'stopped') {
return guest.status === 'stopped';
}
// Fallback to simple text match
return guest.name.toLowerCase().includes(term) ||
guest.id.toLowerCase().includes(term) ||
guest.node.toLowerCase().includes(term);
}
// Run the tests
function runTests() {
const tests = [
{
name: 'Search for primary guests',
term: 'primary',
expected: ['101']
},
{
name: 'Search for secondary guests',
term: 'secondary',
expected: ['102']
},
{
name: 'Search for non-shared guests',
term: 'role:-',
expected: ['104']
},
{
name: 'Search for primary VMs',
term: 'primary vm',
expected: ['101']
},
{
name: 'Search for primary containers',
term: 'primary ct',
expected: ['103']
},
{
name: 'Search for secondary running guests',
term: 'secondary running',
expected: ['102']
},
{
name: 'Search with role prefix',
term: 'role:primary',
expected: ['101', '103']
},
{
name: 'Search with role prefix for secondary',
term: 'role:secondary',
expected: ['102']
}
];
let passedTests = 0;
tests.forEach(test => {
console.log(`Running test: ${test.name}`);
// Filter guests based on the search term
const results = mockGuests.filter(guest => mockMatchesTerm(guest, test.term, mockNodeData));
// Get the IDs of matching guests
const resultIds = results.map(guest => guest.id);
// Check if the results match the expected
const passed = test.expected.length === resultIds.length &&
test.expected.every(id => resultIds.includes(id));
if (passed) {
console.log(`✅ PASSED: "${test.term}" - Found ${resultIds.join(', ')}`);
passedTests++;
} else {
console.log(`❌ FAILED: "${test.term}"`);
console.log(` Expected: ${test.expected.join(', ')}`);
console.log(` Actual: ${resultIds.join(', ')}`);
}
console.log('-----------------------------------');
});
console.log(`${passedTests} of ${tests.length} tests passed.`);
if (passedTests === tests.length) {
console.log('✅ ALL TESTS PASSED - Role search functionality is working correctly.');
} else {
console.log('❌ SOME TESTS FAILED - Role search functionality needs fixing.');
}
}
// Execute the tests
runTests();
// Export for potential use in other test suites
module.exports = { mockMatchesTerm, runTests };
-91
View File
@@ -1,91 +0,0 @@
/**
* Generates a consistent color based on a string input (node name)
* Returns a color with reduced opacity for use in table row backgrounds
*
* @param {string} str - The input string (node name)
* @param {number} opacity - Opacity value between 0 and 1
* @param {string} mode - 'dark' or 'light' theme mode
* @returns {string} - RGBA color string
*/
export const getNodeColor = (str, opacity = 0.1, mode = 'light') => {
if (!str) return mode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.02)';
// Generate a numeric hash from the string
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Different color sets for dark and light modes
const baseColors = mode === 'dark' ? [
[130, 177, 255], // Blue
[255, 145, 143], // Red
[126, 211, 150], // Green
[241, 186, 252], // Pink
[255, 213, 128], // Yellow
[177, 156, 255], // Purple
[158, 230, 240], // Cyan
[255, 169, 119], // Orange
] : [
[25, 118, 210], // Blue
[211, 47, 47], // Red
[46, 125, 50], // Green
[194, 24, 91], // Pink
[255, 145, 0], // Orange
[123, 31, 162], // Purple
[0, 131, 143], // Teal
[109, 76, 65] // Brown
];
// Use the hash to pick a color from the baseColors
const colorIndex = Math.abs(hash) % baseColors.length;
const [r, g, b] = baseColors[colorIndex];
// Return rgba color string with appropriate opacity
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
/**
* Generates a consistent text color based on a string input (node name)
*
* @param {string} str - The input string (node name)
* @param {string} mode - 'dark' or 'light' theme mode
* @returns {string} - RGB color string
*/
export const getNodeTextColor = (str, mode = 'light') => {
if (!str) return mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
// Generate a numeric hash from the string
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Different color sets for dark and light modes
const baseColors = mode === 'dark' ? [
[130, 177, 255], // Blue
[255, 145, 143], // Red
[126, 211, 150], // Green
[241, 186, 252], // Pink
[255, 213, 128], // Yellow
[177, 156, 255], // Purple
[158, 230, 240], // Cyan
[255, 169, 119], // Orange
] : [
[25, 118, 210], // Blue
[211, 47, 47], // Red
[46, 125, 50], // Green
[194, 24, 91], // Pink
[255, 145, 0], // Orange
[123, 31, 162], // Purple
[0, 131, 143], // Teal
[109, 76, 65] // Brown
];
// Use the hash to pick a color from the baseColors
const colorIndex = Math.abs(hash) % baseColors.length;
const [r, g, b] = baseColors[colorIndex];
// Return rgb color string
return `rgb(${r}, ${g}, ${b})`;
};
-173
View File
@@ -1,173 +0,0 @@
// Debug script for testing 'pri' search issue
// Mock implementation of matchesTerm function for testing
function matchesTerm(guest, termLower, nodeData) {
// Prevent operations on undefined/null terms
if (!termLower) return true;
// CASE 3: Standard role terminology
// Matches exact role terms (not as part of other words) using standard terminology
if (termLower === 'shared' || termLower === 'role') {
return !!guest.shared;
}
// Primary role terms: match whole words only, requires shared=true and isPrimary=true
if (termLower === 'primary' || termLower === 'pri') {
if (!guest.shared) return false;
console.log(`Checking primary for ${guest.id}: primaryNode=${guest.primaryNode}, node=${guest.node}, matches=${guest.primaryNode === guest.node}`);
return guest.primaryNode === guest.node;
}
// Secondary role terms: match whole words only, requires shared=true and isPrimary=false
if (termLower === 'secondary' || termLower === 'sec') {
if (!guest.shared) return false;
return guest.primaryNode !== guest.node;
}
// CASE 5: Single character searches (including role abbreviations)
if (termLower.length === 1) {
const searchText = getFullSearchableText(guest, nodeData);
console.log(`Single-char search for ${termLower} in ${guest.id}: "${searchText}"`);
return searchText.includes(termLower);
}
// Default: search in all text fields
const searchText = getFullSearchableText(guest, nodeData);
console.log(`Full search for ${termLower} in ${guest.id}: "${searchText}"`);
return searchText.includes(termLower);
}
// Improved function to get complete searchable text for a guest
function getFullSearchableText(guest, nodeData) {
// Include ALL searchable properties
const nodeName = guest.node || '';
// Build full searchable text by concatenating ALL searchable fields
const searchableFields = [
guest.name || '',
guest.id || '',
guest.status || '',
// Add VM/CT descriptive terms
guest.type === 'qemu' ? 'vm virtual machine' : 'ct container',
nodeName,
// Add role descriptive terms if shared
guest.shared ? (guest.primaryNode === guest.node ? 'primary pri p' : 'secondary sec s') : '',
// Add shared indicator if applicable
guest.shared ? 'shared role' : 'none',
// Any other custom properties that should be searchable
guest.description || '',
guest.tags || ''
];
// Join all fields with spaces and convert to lowercase
const fullSearchText = searchableFields.join(' ').toLowerCase();
return fullSearchText;
}
// Mock test data - simplified version of actual app data
const testGuests = [
{
id: '101',
name: 'web-server',
status: 'running',
type: 'qemu',
node: 'pve-prod-01',
shared: true,
primaryNode: 'pve-prod-01' // This is primary on this node
},
{
id: '102',
name: 'database',
status: 'running',
type: 'qemu',
node: 'pve-prod-01',
shared: true,
primaryNode: 'pve-prod-01' // This is primary on this node
},
{
id: '201',
name: 'cache-server',
status: 'stopped',
type: 'qemu',
node: 'pve-prod-02',
shared: true,
primaryNode: 'pve-prod-01' // This is secondary on this node
}
];
// Mock node data
const nodeData = [
{ id: 'pve-prod-01', name: 'Production Node 1' },
{ id: 'pve-prod-02', name: 'Production Node 2' }
];
// Test specific search terms directly
function testSearch(searchTerm) {
console.log(`\n===== TESTING SEARCH TERM: "${searchTerm}" =====`);
const result = testGuests.filter(guest => {
const matches = matchesTerm(guest, searchTerm.toLowerCase(), nodeData);
console.log(` Guest ${guest.id} (${guest.name}) matches ${searchTerm}? ${matches ? 'YES' : 'NO'}`);
return matches;
});
console.log(`\nResults for "${searchTerm}":`);
console.log(` Found ${result.length} guests:`);
if (result.length === 0) {
console.log(" NO RESULTS FOUND!");
} else {
result.forEach(guest => {
console.log(` - ${guest.id}: ${guest.name} (${guest.node}, shared=${guest.shared}, primaryNode=${guest.primaryNode})`);
console.log(` Primary? ${guest.primaryNode === guest.node ? 'YES' : 'NO'}`);
});
}
// Print expected vs actual for primaries
const expectedPrimaries = testGuests.filter(g => g.shared && g.primaryNode === g.node);
console.log(`\nExpected primaries: ${expectedPrimaries.length} guests`);
expectedPrimaries.forEach(g => console.log(` - ${g.id}: ${g.name}`));
// Verify if all primaries were found
const allPrimariesFound = expectedPrimaries.every(
expected => result.some(res => res.id === expected.id)
);
console.log(`\nAll primaries found? ${allPrimariesFound ? 'YES ✅' : 'NO ❌'}`);
if (!allPrimariesFound) {
console.log("Missing primaries:");
expectedPrimaries.forEach(expected => {
if (!result.some(res => res.id === expected.id)) {
console.log(` - ${expected.id}: ${expected.name}`);
}
});
}
}
// Test various forms of the primary search
console.log("\n********** DEBUGGING 'PRI' SEARCH ISSUE **********");
testSearch('pri');
testSearch('primary');
testSearch('p'); // Single character test
// Test role-prefixed searches to check if they work differently
console.log("\n********** TESTING PREFIXED SEARCHES **********");
testSearch('role:pri');
testSearch('role:primary');
console.log("\n\n********** SEARCH IMPLEMENTATION DETAILS **********");
// Print the relevant implementation from networkUtils.js
console.log(`
Search logic for 'pri' is implemented in networkUtils.js:
// Standard role terminology
if (termLower === 'primary' || termLower === 'pri') {
if (!guest.shared) return false;
return guest.primaryNode === guest.node;
}
and in getFullSearchableText:
guest.shared ? (guest.primaryNode === guest.node ? 'primary pri p' : 'secondary sec s') : '',
`);
-130
View File
@@ -1,130 +0,0 @@
// Helper function to format bytes
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === undefined || bytes === null || isNaN(bytes) || bytes === 0) return '0 B';
const k = 1024;
const dm = 0; // No decimals
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// Ensure bytes is a positive number
bytes = Math.abs(bytes);
// Calculate the appropriate size index
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
// Ensure i is within bounds of the sizes array
if (i < 0 || i >= sizes.length) {
console.error(`Invalid size index: ${i} for bytes: ${bytes}`);
return `${bytes} B`; // Fallback to bytes with B unit
}
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
/**
* Format network rate with unit information
* @param {number} bytesPerSecond - The network rate in bytes per second
* @param {string} unit - Optional unit information ('bytes', 'KB', 'MB', 'GB')
* @returns {string} Formatted string with appropriate unit
*/
export const formatNetworkRate = (bytesPerSecond, unit = 'bytes') => {
if (bytesPerSecond === null || bytesPerSecond === undefined) {
return 'N/A';
}
// If the unit is specified, convert the value back to bytes for consistent formatting
let bytesValue = bytesPerSecond;
if (unit === 'KB') {
bytesValue = bytesPerSecond * 1024;
} else if (unit === 'MB') {
bytesValue = bytesPerSecond * 1024 * 1024;
} else if (unit === 'GB') {
bytesValue = bytesPerSecond * 1024 * 1024 * 1024;
}
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
let value = bytesValue;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
// Format with appropriate precision
return value < 10 ?
`${value.toFixed(1)} ${units[unitIndex]}` :
`${Math.round(value)} ${units[unitIndex]}`;
};
// Helper function to format percentage
export const formatPercentage = (value) => {
if (value === undefined || value === null || isNaN(value)) return '0%';
return `${Math.round(value)}%`;
};
// Helper function to format uptime duration
export const formatUptime = (seconds) => {
if (seconds === undefined || seconds === null || isNaN(seconds) || seconds === 0) return '-';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
// Helper function to format network rates for filter display
export const formatNetworkRateForFilter = (bytesPerSecond) => {
if (bytesPerSecond === undefined || bytesPerSecond === null || isNaN(bytesPerSecond) || bytesPerSecond === 0) return '0 B/s';
// Simplified format for filter display
const kb = bytesPerSecond / 1024;
if (kb < 1000) {
return `${Math.round(kb)} KB/s`;
} else {
return `${Math.round(kb/1024)} MB/s`;
}
};
// Convert slider value (0-100) to actual bytes per second
export const sliderValueToNetworkRate = (value) => {
// Max realistic rate for filter: ~10 MB/s = 10485760 B/s
return value * 104858; // This gives us a range from 0 to ~10 MB/s
};
// Convert network rate to slider value (0-100)
export const networkRateToSliderValue = (bytesPerSecond) => {
return Math.min(100, Math.round(bytesPerSecond / 104858));
};
/**
* Format bytes with unit information
* @param {number} bytes - The bytes value to format
* @param {string} unit - Optional unit information ('bytes', 'KB', 'MB', 'GB')
* @returns {string} Formatted string with appropriate unit
*/
export const formatBytesWithUnit = (bytes, unit = 'bytes') => {
if (bytes === null || bytes === undefined) {
return 'N/A';
}
// If the unit is specified, convert the value back to bytes for consistent formatting
let bytesValue = bytes;
if (unit === 'KB') {
bytesValue = bytes * 1024;
} else if (unit === 'MB') {
bytesValue = bytes * 1024 * 1024;
} else if (unit === 'GB') {
bytesValue = bytes * 1024 * 1024 * 1024;
}
// Now format using the standard formatter
return formatBytes(bytesValue);
};
File diff suppressed because it is too large Load Diff
-193
View File
@@ -1,193 +0,0 @@
// Standalone test for 'pri' search
// Simple script to test that 'pri' search works properly
// Mock implementation of required functions
function matchesTerm(guest, termLower, nodeData) {
// Prevent operations on undefined/null terms
if (!termLower) return true;
// Handle column-specific searches
if (termLower.includes(':')) {
const [prefix, value] = termLower.split(':', 2);
if (prefix.trim().toLowerCase() === 'role') {
const roleValue = (value || '').trim().toLowerCase();
// Non-shared checks
if (roleValue === '-' || roleValue === 'none') {
return !guest.shared;
}
// Need to be shared for other role searches
if (!guest.shared) return false;
const isPrimary = guest.primaryNode === guest.node;
// Primary checks
if (roleValue === 'p' || roleValue.startsWith('pri') || roleValue === 'primary') {
console.log(`Column search ${termLower} for ${guest.id}: isPrimary=${isPrimary}`);
return isPrimary;
}
// Secondary checks
if (roleValue === 's' || roleValue.startsWith('sec') || roleValue === 'secondary') {
return !isPrimary;
}
return false;
}
// Unknown column, just do text search
return getFullSearchableText(guest).includes(termLower);
}
// Standard role terminology
if (termLower === 'primary' || termLower === 'pri') {
console.log(`Standard term "${termLower}" check for ${guest.id}: shared=${guest.shared}, isPrimary=${guest.primaryNode === guest.node}`);
if (!guest.shared) return false;
return guest.primaryNode === guest.node;
}
// Secondary role terms
if (termLower === 'secondary' || termLower === 'sec') {
if (!guest.shared) return false;
return guest.primaryNode !== guest.node;
}
// Single letter searches
if (termLower.length === 1) {
const searchText = getFullSearchableText(guest);
console.log(`Single char search "${termLower}" for ${guest.id} in: "${searchText}"`);
return searchText.includes(termLower);
}
// Default text search
return getFullSearchableText(guest).includes(termLower);
}
function getFullSearchableText(guest) {
// Build full searchable text
const searchableFields = [
guest.name || '',
guest.id || '',
guest.status || '',
guest.type === 'qemu' ? 'vm virtual machine' : 'ct container',
guest.node || '',
guest.shared ? (guest.primaryNode === guest.node ? 'primary pri p' : 'secondary sec s') : '',
guest.shared ? 'shared role' : 'none'
];
return searchableFields.join(' ').toLowerCase();
}
// Mock guests
const guests = [
{
id: '101',
name: 'web-server-primary',
node: 'node1',
shared: true,
primaryNode: 'node1', // Primary role on current node
status: 'running',
type: 'qemu'
},
{
id: '102',
name: 'db-primary',
node: 'node1',
shared: true,
primaryNode: 'node1', // Primary role on current node
status: 'running',
type: 'qemu'
},
{
id: '201',
name: 'web-server-secondary',
node: 'node2',
shared: true,
primaryNode: 'node1', // Secondary role on current node (node2)
status: 'running',
type: 'qemu'
},
{
id: '202',
name: 'db-secondary',
node: 'node2',
shared: true,
primaryNode: 'node1', // Secondary role on current node (node2)
status: 'running',
type: 'qemu'
},
{
id: '301',
name: 'standalone-app',
node: 'node3',
shared: false, // Not shared
status: 'running',
type: 'qemu'
},
{
id: '302',
name: 'prince-app', // Has "pri" in the name, but not primary
node: 'node3',
shared: false,
status: 'running',
type: 'qemu'
}
];
// Run tests for various search terms
function runTest(searchTerm) {
console.log(`\n===== TESTING SEARCH FOR: "${searchTerm}" =====`);
const results = guests.filter(guest => {
const matches = matchesTerm(guest, searchTerm.toLowerCase(), []);
const searchableText = getFullSearchableText(guest);
console.log(`Guest ${guest.id} (${guest.name}): ${matches ? 'MATCH' : 'NO MATCH'}`);
console.log(` Shared: ${guest.shared}, Primary node: ${guest.primaryNode}, Current node: ${guest.node}`);
console.log(` Is Primary on this node? ${guest.shared && guest.primaryNode === guest.node}`);
console.log(` Searchable text: "${searchableText}"`);
console.log(` Contains 'pri'? ${searchableText.includes('pri')}`);
console.log(` Contains 'primary'? ${searchableText.includes('primary')}`);
return matches;
});
console.log(`\nRESULTS FOR "${searchTerm}":`);
console.log(` Found ${results.length} matches:`);
if (results.length > 0) {
results.forEach(r => console.log(` - ${r.id}: ${r.name}`));
} else {
console.log(' NO MATCHES');
}
// Check expectation - primary search should find primary nodes
const expectedPrimaries = guests.filter(g => g.shared && g.primaryNode === g.node);
if (searchTerm === 'pri' || searchTerm === 'primary' || searchTerm === 'role:pri' || searchTerm === 'role:primary') {
const allPrimariesFound = expectedPrimaries.every(p => results.some(r => r.id === p.id));
const onlyPrimariesFound = results.every(r => expectedPrimaries.some(p => p.id === r.id));
console.log(`\nTEST RESULTS:`);
console.log(` Expected ${expectedPrimaries.length} primaries: ${expectedPrimaries.map(p => p.id).join(', ')}`);
console.log(` All primary nodes found? ${allPrimariesFound ? 'YES ✅' : 'NO ❌'}`);
console.log(` Only primary nodes found? ${onlyPrimariesFound ? 'YES ✅' : 'NO ❌'}`);
if (!allPrimariesFound || !onlyPrimariesFound) {
console.log(`\n❌ TEST FAILED`);
} else {
console.log(`\n✅ TEST PASSED`);
}
}
}
console.log("🔍 TESTING 'PRI' SEARCH FUNCTIONALITY");
console.log("===================================");
runTest('pri');
runTest('primary');
runTest('role:pri');
runTest('role:primary');
runTest('p'); // Should match primary guests and others containing 'p'
runTest('prince'); // Should match guest 302 ("prince-app")
-15
View File
@@ -1,15 +0,0 @@
/**
* Test Runner for Search Functionality Tests
* Run with: node runSearchTests.js
*/
require('@babel/register')({
presets: ['@babel/preset-env'],
plugins: [
'@babel/plugin-transform-modules-commonjs'
]
});
// Import and run the tests
const { runSearchTests } = require('./searchTests');
runSearchTests();
-557
View File
@@ -1,557 +0,0 @@
/**
* Search Functionality Test Suite
*
* This file contains tests for verifying the search functionality in the NetworkUtils module.
* Run this file using Node.js to verify all search capabilities are working correctly.
*/
import { getSortedAndFilteredData } from './networkUtils';
// Mock data for testing search functionality - expanded to better match real application structure
const mockGuests = [
// Primary node guests
{
id: '101',
name: 'web-server',
type: 'qemu',
status: 'running',
node: 'node-1',
shared: true,
primaryNode: 'node-1',
cpu: 0.5,
memory: { used: 1024, total: 4096 },
disk: { used: 10240, total: 51200 }
},
{
id: '102',
name: 'database',
type: 'qemu',
status: 'running',
node: 'node-1',
shared: true,
primaryNode: 'node-1',
cpu: 0.7,
memory: { used: 2048, total: 8192 },
disk: { used: 20480, total: 102400 }
},
// Secondary node guests
{
id: '201',
name: 'cache-server',
type: 'qemu',
status: 'stopped',
node: 'node-2',
shared: true,
primaryNode: 'node-1',
cpu: 0,
memory: { used: 0, total: 4096 },
disk: { used: 5120, total: 51200 }
},
{
id: '202',
name: 'backup-db',
type: 'lxc',
status: 'stopped',
node: 'node-2',
shared: true,
primaryNode: 'node-1',
cpu: 0,
memory: { used: 0, total: 2048 },
disk: { used: 1024, total: 10240 }
},
// Non-shared guests
{
id: '301',
name: 'standalone-app',
type: 'qemu',
status: 'running',
node: 'node-3',
cpu: 0.3,
memory: { used: 1536, total: 4096 },
disk: { used: 15360, total: 51200 }
},
{
id: '302',
name: 'standalone-container',
type: 'lxc',
status: 'stopped',
node: 'node-3',
cpu: 0,
memory: { used: 0, total: 1024 },
disk: { used: 512, total: 5120 }
}
];
// Mock node data
const mockNodeData = [
{ id: 'node-1', name: 'pve-prod-01' },
{ id: 'node-2', name: 'pve-prod-02' },
{ id: 'node-3', name: 'pve-dev-01' }
];
// Mock metrics data
const mockMetricsData = {
cpu: {
'101': { usage: 0.5 },
'102': { usage: 0.7 },
'201': { usage: 0 },
'202': { usage: 0 },
'301': { usage: 0.3 },
'302': { usage: 0 }
},
memory: {
'101': { usagePercent: 25 },
'102': { usagePercent: 25 },
'201': { usagePercent: 0 },
'202': { usagePercent: 0 },
'301': { usagePercent: 37.5 },
'302': { usagePercent: 0 }
},
disk: {
'101': { usagePercent: 20 },
'102': { usagePercent: 20 },
'201': { usagePercent: 10 },
'202': { usagePercent: 10 },
'301': { usagePercent: 30 },
'302': { usagePercent: 10 }
},
network: {
'101': { inRate: 500 * 1024, outRate: 300 * 1024 }, // Running system
'102': { inRate: 800 * 1024, outRate: 400 * 1024 }, // Running system
'201': { inRate: 0, outRate: 0 }, // Stopped system
'202': { inRate: 0, outRate: 0 }, // Stopped system
'301': { inRate: 350 * 1024, outRate: 150 * 1024 }, // Running system
'302': { inRate: 0, outRate: 0 } // Stopped system
}
};
// Deep clone function to ensure we don't accidentally modify test data
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// Test helper to check if two arrays have the same elements (order doesn't matter)
function arraysHaveSameElements(arr1, arr2) {
if (arr1.length !== arr2.length) return false;
const sortedArr1 = [...arr1].sort();
const sortedArr2 = [...arr2].sort();
return JSON.stringify(sortedArr1) === JSON.stringify(sortedArr2);
}
// Test a single search term directly
function testSingleSearchTerm(term, expectedIds) {
console.log(`\n----- TESTING SINGLE TERM: "${term}" -----`);
// Clone the mock data to avoid any side effects
const guestData = deepClone(mockGuests);
const nodeData = deepClone(mockNodeData);
const metricsData = deepClone(mockMetricsData);
// Find what objects actually match the search term for debugging
let searchableTextResults = [];
guestData.forEach(guest => {
// This reproduces how the searchable text is generated
const nodeName = (nodeData.find(n => n.id === guest.node) || {}).name || guest.node || '';
const isShared = guest.shared || false;
const isPrimary = isShared && guest.primaryNode === guest.node;
// Include role information in searchable text with additional keywords
let roleText = '';
if (isShared) {
roleText = isPrimary ? 'primary pri' : 'secondary sec';
}
const searchableText = [
guest.name || '',
guest.id || '',
guest.status || '',
guest.type === 'qemu' ? 'vm virtual machine' : 'ct container',
nodeName,
roleText
].join(' ').toLowerCase();
const match = searchableText.includes(term.toLowerCase());
if (match) {
searchableTextResults.push({
id: guest.id,
searchableText
});
}
});
console.log(`Searchable text matches for "${term}":`);
if (searchableTextResults.length === 0) {
console.log(' NONE FOUND in searchable text');
} else {
searchableTextResults.forEach(result => {
console.log(` ID ${result.id}: "${result.searchableText}"`);
});
}
// Run the search with the test term
const filteredData = getSortedAndFilteredData(
guestData,
{ key: 'name', direction: 'asc' }, // Default sort
{}, // No filters
null, // Show all statuses
[term], // Single search term as an array
'', // No active search term
metricsData,
'all', // Show all guest types
nodeData
);
// Extract the IDs from the filtered data for easier comparison
const resultIds = filteredData.map(guest => guest.id);
// Check if the result matches the expected result
const passed = arraysHaveSameElements(resultIds, expectedIds);
// Detailed output of results
console.log(`\nSearch results for "${term}":`);
console.log(` Expected IDs: ${JSON.stringify(expectedIds)}`);
console.log(` Actual IDs: ${JSON.stringify(resultIds)}`);
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
if (!passed) {
console.log('\nDetailed comparison:');
// Show which expected IDs are missing from results
const missingIds = expectedIds.filter(id => !resultIds.includes(id));
if (missingIds.length > 0) {
console.log(` Missing IDs (expected but not found): ${JSON.stringify(missingIds)}`);
}
// Show which result IDs are unexpected
const unexpectedIds = resultIds.filter(id => !expectedIds.includes(id));
if (unexpectedIds.length > 0) {
console.log(` Unexpected IDs (found but not expected): ${JSON.stringify(unexpectedIds)}`);
}
}
return {
term,
expectedIds,
resultIds,
passed,
searchableTextResults
};
}
// Role-specific test runner
function testRoleSearches() {
console.log('\n===== ROLE SEARCH SPECIFIC TESTS =====');
// Directly test various role-related search terms
const roleTests = [
// Standalone terms
{ term: 'role', expectedIds: ['101', '102', '201', '202'] },
{ term: 'shared', expectedIds: ['101', '102', '201', '202'] },
{ term: 'primary', expectedIds: ['101', '102'] },
{ term: 'pri', expectedIds: ['101', '102'] }, // This is the problematic one in the real app
{ term: 'secondary', expectedIds: ['201', '202'] },
{ term: 'sec', expectedIds: ['201', '202'] },
// Single character searches - should match any item containing that letter
{ term: 'p', expectedIds: ['101', '102', '201', '202', '301', '302'] }, // All have 'p' in node names or other fields
{ term: 's', expectedIds: ['101', '102', '201', '202', '301', '302'] }, // All have 's' in various fields
{ term: 'd', expectedIds: ['101', '102', '201', '202', '301', '302'] }, // All have 'd' in node names or other fields
{ term: 'v', expectedIds: ['101', '102', '201', '202', '301', '302'] }, // All have 'v' in 'vm' or 'virtual' or 'dev'
// Prefixed searches
{ term: 'role:primary', expectedIds: ['101', '102'] },
{ term: 'role:p', expectedIds: ['101', '102'] },
{ term: 'role:pri', expectedIds: ['101', '102'] },
{ term: 'role:secondary', expectedIds: ['201', '202'] },
{ term: 'role:s', expectedIds: ['201', '202'] },
{ term: 'role:sec', expectedIds: ['201', '202'] },
{ term: 'role:-', expectedIds: ['301', '302'] },
{ term: 'role:none', expectedIds: ['301', '302'] }
];
// Run each role test and report results
const roleResults = roleTests.map(test => testSingleSearchTerm(test.term, test.expectedIds));
// Summary
const passedRoleTests = roleResults.filter(r => r.passed).length;
console.log('\n----- ROLE SEARCH TESTS SUMMARY -----');
console.log(`Total: ${roleTests.length}, Passed: ${passedRoleTests}, Failed: ${roleTests.length - passedRoleTests}`);
if (passedRoleTests < roleTests.length) {
console.log('\nFailed role tests:');
roleResults
.filter(r => !r.passed)
.forEach(r => {
console.log(` "${r.term}": Expected ${JSON.stringify(r.expectedIds)}, got ${JSON.stringify(r.resultIds)}`);
});
}
return roleResults;
}
// Test runner function
function runSearchTests() {
console.log('===== SEARCH FUNCTIONALITY TEST SUITE =====');
// A simple test harness to run the tests
const tests = [
// Basic text search tests
{
name: 'Basic text search',
terms: ['server'],
expectedIds: ['101', '201'],
description: 'Should find guests containing "server" in their name'
},
{
name: 'Empty search',
terms: [''],
expectedIds: ['101', '102', '201', '202', '301', '302'],
description: 'Empty search should return all guests'
},
// Type search tests
{
name: 'VM type search',
terms: ['vm'],
expectedIds: ['101', '102', '201', '301'],
description: 'Should find all VM guests'
},
{
name: 'Container type search',
terms: ['ct'],
expectedIds: ['202', '302'],
description: 'Should find all container guests'
},
{
name: 'Type:vm search',
terms: ['type:vm'],
expectedIds: ['101', '102', '201', '301'],
description: 'Column-specific search for VMs'
},
// Status search tests
{
name: 'Running status search',
terms: ['running'],
expectedIds: ['101', '102', '301'],
description: 'Should find running guests'
},
{
name: 'Status:stopped search',
terms: ['status:stopped'],
expectedIds: ['201', '202', '302'],
description: 'Column-specific search for stopped guests'
},
// Node search tests
{
name: 'Node search by name',
terms: ['prod-01'],
expectedIds: ['101', '102'],
description: 'Should find guests on node "prod-01"'
},
{
name: 'Node:node-2 search',
terms: ['node:node-2'],
expectedIds: ['201', '202'],
description: 'Column-specific search for node-2'
},
// Role search tests - these are the ones having issues
{
name: 'Role search (standalone term)',
terms: ['role'],
expectedIds: ['101', '102', '201', '202'],
description: 'Should find all shared guests (primary or secondary)'
},
{
name: 'Shared search (alternative)',
terms: ['shared'],
expectedIds: ['101', '102', '201', '202'],
description: 'Alternative to "role" search'
},
{
name: 'Primary search (standalone)',
terms: ['primary'],
expectedIds: ['101', '102'],
description: 'Should find primary guests without using role prefix'
},
{
name: 'Pri search (short form)',
terms: ['pri'],
expectedIds: ['101', '102'],
description: 'Short form of primary search'
},
{
name: 'Secondary search (standalone)',
terms: ['secondary'],
expectedIds: ['201', '202'],
description: 'Should find secondary guests without using role prefix'
},
{
name: 'Sec search (short form)',
terms: ['sec'],
expectedIds: ['201', '202'],
description: 'Short form of secondary search'
},
{
name: 'Role:primary search',
terms: ['role:primary'],
expectedIds: ['101', '102'],
description: 'Column-specific search for primary role'
},
{
name: 'Role:p search (partial)',
terms: ['role:p'],
expectedIds: ['101', '102'],
description: 'Column-specific search with partial term for primary'
},
{
name: 'Role:pri search (partial)',
terms: ['role:pri'],
expectedIds: ['101', '102'],
description: 'Column-specific search with partial term for primary'
},
{
name: 'Role:secondary search',
terms: ['role:secondary'],
expectedIds: ['201', '202'],
description: 'Column-specific search for secondary role'
},
{
name: 'Role:s search (partial)',
terms: ['role:s'],
expectedIds: ['201', '202'],
description: 'Column-specific search with partial term for secondary'
},
{
name: 'Role:sec search (partial)',
terms: ['role:sec'],
expectedIds: ['201', '202'],
description: 'Column-specific search with partial term for secondary'
},
{
name: 'Role:- search (non-shared)',
terms: ['role:-'],
expectedIds: ['301', '302'],
description: 'Column-specific search for non-shared guests'
},
{
name: 'Role:none search (non-shared)',
terms: ['role:none'],
expectedIds: ['301', '302'],
description: 'Column-specific search for non-shared guests, alternative'
},
// Multiple term search tests
{
name: 'Multiple terms (AND logic)',
terms: ['primary', 'database'],
expectedIds: ['102'],
description: 'Should find guests matching both terms (primary AND database)'
}
];
// Run general tests first
const results = {
passed: 0,
failed: 0,
details: []
};
tests.forEach(test => {
process.stdout.write(`Testing: ${test.name} ... `);
// Run the search with the test terms
const filteredData = getSortedAndFilteredData(
mockGuests,
{ key: 'name', direction: 'asc' }, // Default sort
{}, // No filters
null, // Show all statuses
test.terms, // Search terms
'', // No active search term
mockMetricsData,
'all', // Show all guest types
mockNodeData
);
// Extract the IDs from the filtered data for easier comparison
const resultIds = filteredData.map(guest => guest.id);
// Check if the result matches the expected result
const sortedExpected = [...test.expectedIds].sort();
const sortedResults = [...resultIds].sort();
const passed = JSON.stringify(sortedExpected) === JSON.stringify(sortedResults);
if (passed) {
console.log('✅ PASSED');
results.passed++;
} else {
console.log('❌ FAILED');
console.log(` Expected: ${JSON.stringify(sortedExpected)}`);
console.log(` Actual: ${JSON.stringify(sortedResults)}`);
results.failed++;
}
results.details.push({
...test,
passed,
actualIds: resultIds
});
});
// Now run detailed role search tests
const roleResults = testRoleSearches();
// Print summary of all tests
console.log('\n===== COMPLETE TEST SUMMARY =====');
console.log(`General tests: ${tests.length}, Passed: ${results.passed}, Failed: ${results.failed}`);
const passedRoleTests = roleResults.filter(r => r.passed).length;
const failedRoleTests = roleResults.length - passedRoleTests;
console.log(`Role tests: ${roleResults.length}, Passed: ${passedRoleTests}, Failed: ${failedRoleTests}`);
console.log(`Total: ${tests.length + roleResults.length}, Passed: ${results.passed + passedRoleTests}, Failed: ${results.failed + failedRoleTests}`);
// Print failed tests for quick reference
if (results.failed > 0 || failedRoleTests > 0) {
console.log('\n===== FAILED TESTS =====');
// Failed general tests
results.details
.filter(detail => !detail.passed)
.forEach(detail => {
console.log(`${detail.name}: ${detail.description}`);
console.log(` Expected: ${JSON.stringify(detail.expectedIds)}`);
console.log(` Actual: ${JSON.stringify(detail.actualIds)}`);
});
// Failed role tests
roleResults
.filter(r => !r.passed)
.forEach(r => {
console.log(`❌ Role test "${r.term}"`);
console.log(` Expected: ${JSON.stringify(r.expectedIds)}`);
console.log(` Actual: ${JSON.stringify(r.resultIds)}`);
});
}
return {
general: results,
role: roleResults
};
}
// For programmatic usage
export function runTermTest(term, expectedIds) {
return testSingleSearchTerm(term, expectedIds);
}
// Run the tests when this file is executed directly
if (typeof require !== 'undefined' && require.main === module) {
runSearchTests();
}
export { runSearchTests, testRoleSearches, testSingleSearchTerm };
-165
View File
@@ -1,165 +0,0 @@
/**
* Utility functions for managing localStorage data
* These functions help ensure proper cleanup when switching between environments
*/
// List of all localStorage keys used by the application
const APP_STORAGE_KEYS = [
// Network display filters
'network_display_filters',
'network_display_show_stopped',
'network_display_show_filters',
'network_display_search_terms',
'network_display_guest_type_filter',
'network_display_column_visibility',
'network_display_column_order',
'network_display_sort',
// Theme settings
'app_dark_mode',
'app_filter_state',
// Mock data settings
'use_mock_data',
'MOCK_DATA_ENABLED',
'mock_enabled',
'MOCK_SERVER_URL',
// Session data
'last_environment',
'guest_data_cache'
];
/**
* Clear all application data from localStorage
* This should be called when switching between environments
*/
export const clearAppData = () => {
console.log('Clearing all application data from localStorage');
try {
// Clear each key individually
APP_STORAGE_KEYS.forEach(key => {
localStorage.removeItem(key);
});
// Also clear any other items that might have been added
// This ensures we don't miss anything
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('pulse_') || key.includes('guest') || key.includes('node') || key.includes('mock')) {
localStorage.removeItem(key);
}
}
// Set the current environment
localStorage.setItem('last_environment', process.env.NODE_ENV || 'development');
console.log('Successfully cleared application data');
return true;
} catch (error) {
console.error('Error clearing application data:', error);
return false;
}
};
/**
* Force clear all data regardless of environment
* This can be called manually when needed
*/
export const forceClearAllData = () => {
console.log('Force clearing all application data');
try {
// Clear all localStorage items
localStorage.clear();
// Set the current environment
localStorage.setItem('last_environment', process.env.NODE_ENV || 'development');
console.log('Successfully force cleared all application data');
return true;
} catch (error) {
console.error('Error force clearing application data:', error);
return false;
}
};
/**
* Check if we need to clear data (when switching between environments)
* Returns true if data was cleared
*/
export const checkAndClearDataIfNeeded = () => {
try {
const lastEnvironment = localStorage.getItem('last_environment');
const currentEnvironment = process.env.NODE_ENV || 'development';
// If the environment has changed, clear the data
if (lastEnvironment && lastEnvironment !== currentEnvironment) {
console.log(`Environment changed from ${lastEnvironment} to ${currentEnvironment}, clearing data`);
return clearAppData();
}
// If this is the first run (no last environment), set it
if (!lastEnvironment) {
localStorage.setItem('last_environment', currentEnvironment);
}
return false;
} catch (error) {
console.error('Error checking environment change:', error);
return false;
}
};
/**
* Clear all mock data settings from localStorage
* This should be called at application startup to ensure
* we start with a clean state and respect environment variables
*/
export const clearMockDataSettings = () => {
console.log('Cleaning up mock data settings to use server-side only');
try {
// Remove all client-side mock data related keys
localStorage.removeItem('mock_enabled');
localStorage.removeItem('MOCK_SERVER_URL');
localStorage.removeItem('MOCK_DATA');
// Also clear any other mock-related items that might be client-side
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('mock') && key !== 'use_mock_data' && key !== 'MOCK_DATA_ENABLED')) {
localStorage.removeItem(key);
}
}
// Clean up any window globals used for client-side mocking
if (typeof window !== 'undefined') {
if (window.MOCK_DATA) {
delete window.MOCK_DATA;
}
}
console.log('Successfully cleaned up mock data settings for server-side only use');
return true;
} catch (error) {
console.error('Error clearing mock data settings:', error);
return false;
}
};
/**
* Initialize the storage system
* This should be called when the application starts
*/
export const initializeStorage = () => {
// First clear any mock data settings to ensure we start fresh
clearMockDataSettings();
// Then check if we need to clear other data
const wasCleared = checkAndClearDataIfNeeded();
// Return whether data was cleared
return wasCleared;
};
-119
View File
@@ -1,119 +0,0 @@
# Systematic Search Test Suite
This directory contains a comprehensive, systematic test suite for verifying the search functionality in the NetworkUtils module. The test suite is designed to be thorough, covering all edge cases, boundary conditions, and search patterns to ensure the search functionality behaves as expected.
## Overview
The search test implementation follows a formal, methodical approach to ensure complete coverage of all search features. It tests:
- Basic text searching
- ID-based searching
- Status filtering (running, stopped, paused, suspended)
- Type filtering (VM, container)
- Node filtering
- Role searching (primary, secondary, non-shared)
- Metric-based filtering (CPU, memory, disk usage)
- Tag searching
- Multiple term combinations
- Edge cases
- Complex queries
- Single character searches
## Test Data
The test suite uses comprehensive mock data that represents various real-world scenarios:
- Guests with different statuses (running, stopped, paused, suspended)
- VMs and containers
- Shared and non-shared guests
- Primary and secondary nodes
- Guests with varying resource usage (high/low CPU, memory, disk)
- Guests with different tags and descriptions
## Running the Tests
The test suite provides several ways to run tests:
### Run All Tests
```bash
node frontend/src/utils/tests/runSystematicSearchTests.js all
```
This will run all test categories and generate a complete report.
### Run a Specific Test Category
```bash
node frontend/src/utils/tests/runSystematicSearchTests.js category "Category Name"
```
Available categories:
- Basic Text Search
- ID Search
- Status Search
- Type Search
- Node Search
- Role Search
- Metric Search
- Tag Search
- Multiple Term Search
- Edge Cases
- Combinations & Complex Queries
- Single Character Searches
### Test a Single Search Term
```bash
node frontend/src/utils/tests/runSystematicSearchTests.js term "search term" "id1,id2,id3"
```
This allows testing a specific search term against expected results.
### Diagnose a Search Term
```bash
node frontend/src/utils/tests/runSystematicSearchTests.js diagnose "search term"
```
This generates a detailed diagnostic report for a specific search term, showing which guests match and which don't.
### Generate Feature Matrix
```bash
node frontend/src/utils/tests/runSystematicSearchTests.js matrix
```
This generates a matrix of all search features with test counts for each feature.
## Test Structure
The test suite is organized by feature categories, with each category containing multiple test cases. Each test case specifies:
1. A search term or terms to test
2. The expected matching guest IDs
3. A description of what the test is verifying
Tests are executed systematically, with detailed reporting of any failures, including:
- Which specific tests failed
- What the expected vs. actual results were
- Which specific guests were missing or unexpected
- Details about the missing or unexpected guests
## Extending the Tests
To add new test cases:
1. Add a new test object to the appropriate category in the `testCategories` array
2. Specify the term, expectedIds, and description
3. If necessary, add new mock data to represent the scenario being tested
For new feature categories:
1. Add a new category object to the `testCategories` array
2. Add test cases to the new category
3. Update the documentation in this README and the runSystematicSearchTests.js help text
## Automated Integration
This test suite can be integrated into CI/CD pipelines by running the `all` command and checking the exit code (0 for success, 1 for failures).
@@ -1,154 +0,0 @@
/**
* Debug file for testing spaced expressions
*/
// Mock metrics data with values specifically for threshold testing
const mockMetricsData = {
cpu: {
'101': { usage: 40, cores: 2 },
'102': { usage: 75, cores: 4 },
'103': { usage: 25, cores: 1 },
'104': { usage: 60, cores: 2 },
'105': { usage: 90, cores: 8 }
},
memory: {
'101': { used: 1024, total: 4096, usagePercent: 25 },
'102': { used: 6144, total: 8192, usagePercent: 75 },
'103': { used: 512, total: 1024, usagePercent: 50 },
'104': { used: 3072, total: 4096, usagePercent: 80 },
'105': { used: 14336, total: 16384, usagePercent: 90 }
},
disk: {
'101': { used: 10240, total: 51200, usagePercent: 20 },
'102': { used: 76800, total: 102400, usagePercent: 75 },
'103': { used: 2560, total: 5120, usagePercent: 50 },
'104': { used: 40960, total: 51200, usagePercent: 85 },
'105': { used: 92160, total: 102400, usagePercent: 95 }
}
};
// Mock guest data with clear names indicating their metrics
const mockGuests = [
{ id: '101', name: 'low-usage', type: 'qemu', status: 'running', node: 'node-1' },
{ id: '102', name: 'medium-usage', type: 'qemu', status: 'running', node: 'node-1' },
{ id: '103', name: 'very-low-usage', type: 'lxc', status: 'running', node: 'node-2' },
{ id: '104', name: 'high-usage', type: 'qemu', status: 'running', node: 'node-2' },
{ id: '105', name: 'very-high-usage', type: 'qemu', status: 'running', node: 'node-3' }
];
// Define the regex patterns
const resourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i;
const directExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)(>|<|>=|<=|=)(\d+)$/i;
const spacedExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i;
// Function to test if a term matches a guest
function matchesTerm(guest, termLower, metricsData) {
console.log(`Testing term "${termLower}" for guest ${guest.id} (${guest.name})`);
// Check for spaced expressions like "cpu > 50"
const spacedMatch = termLower.match(spacedExpressionRegex);
if (spacedMatch) {
console.log(` Matched spacedExpressionRegex: ${JSON.stringify(spacedMatch)}`);
let resource = spacedMatch[1].toLowerCase();
const memoryCapture = spacedMatch[2]; // Capture the optional (ory) part
if (resource === 'mem' || (resource === 'mem' && memoryCapture)) {
resource = 'memory';
}
const operator = spacedMatch[3];
const value = parseFloat(spacedMatch[4]);
console.log(` Resource: ${resource}, Operator: ${operator}, Value: ${value}`);
// Get the metric value
let metricValue = 0;
if (resource === 'cpu') {
metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0;
} else if (resource === 'memory') {
metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'disk') {
metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0;
}
console.log(` Metric value: ${metricValue}`);
// Apply operator
let result = false;
switch (operator) {
case '>': result = metricValue > value; break;
case '<': result = metricValue < value; break;
case '>=': result = metricValue >= value; break;
case '<=': result = metricValue <= value; break;
case '=': result = metricValue === value; break;
}
console.log(` Result: ${result}`);
return result;
}
// Check for direct expressions like "cpu>50"
const directMatch = termLower.match(directExpressionRegex);
if (directMatch) {
console.log(` Matched directExpressionRegex: ${JSON.stringify(directMatch)}`);
let resource = directMatch[1].toLowerCase();
const memoryCapture = directMatch[2]; // Capture the optional (ory) part
if (resource === 'mem' || (resource === 'mem' && memoryCapture)) {
resource = 'memory';
}
const operator = directMatch[3];
const value = parseFloat(directMatch[4]);
console.log(` Resource: ${resource}, Operator: ${operator}, Value: ${value}`);
// Get the metric value
let metricValue = 0;
if (resource === 'cpu') {
metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0;
} else if (resource === 'memory') {
metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'disk') {
metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0;
}
console.log(` Metric value: ${metricValue}`);
// Apply operator
let result = false;
switch (operator) {
case '>': result = metricValue > value; break;
case '<': result = metricValue < value; break;
case '>=': result = metricValue >= value; break;
case '<=': result = metricValue <= value; break;
case '=': result = metricValue === value; break;
}
console.log(` Result: ${result}`);
return result;
}
console.log(` No match found for term "${termLower}"`);
return false;
}
// Test the spaced expressions
console.log("\n=== Testing CPU > 50 ===");
const cpuResults = mockGuests.filter(guest => matchesTerm(guest, "cpu > 50", mockMetricsData));
console.log("Guests matching 'cpu > 50':", cpuResults.map(g => g.id));
console.log("\n=== Testing Memory > 50 ===");
const memResults = mockGuests.filter(guest => matchesTerm(guest, "memory > 50", mockMetricsData));
console.log("Guests matching 'memory > 50':", memResults.map(g => g.id));
console.log("\n=== Testing Disk > 50 ===");
const diskResults = mockGuests.filter(guest => matchesTerm(guest, "disk > 50", mockMetricsData));
console.log("Guests matching 'disk > 50':", diskResults.map(g => g.id));
// Test direct expressions for comparison
console.log("\n=== Testing CPU>50 ===");
const cpuDirectResults = mockGuests.filter(guest => matchesTerm(guest, "cpu>50", mockMetricsData));
console.log("Guests matching 'cpu>50':", cpuDirectResults.map(g => g.id));
@@ -1,267 +0,0 @@
/**
* Metric Threshold Filtering Tests
*
* This test suite specifically tests the ability to filter guests using metric threshold
* expressions such as "cpu>50", "memory>75", etc.
*/
import { getSortedAndFilteredData } from './mocks/networkUtils.js';
// For direct testing of the matchesTerm function
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// TEST DATA SETUP
// ============================================================================
// Mock node data with realistic node properties
const mockNodeData = [
{ id: 'node-1', name: 'prod-01', status: 'online' },
{ id: 'node-2', name: 'prod-02', status: 'online' },
{ id: 'node-3', name: 'stage-01', status: 'online' }
];
// Mock metrics data with values specifically for threshold testing
const mockMetricsData = {
cpu: {
'101': { usage: 40, cores: 2 },
'102': { usage: 75, cores: 4 },
'103': { usage: 25, cores: 1 },
'104': { usage: 60, cores: 2 },
'105': { usage: 90, cores: 8 }
},
memory: {
'101': { used: 1024, total: 4096, usagePercent: 25 },
'102': { used: 6144, total: 8192, usagePercent: 75 },
'103': { used: 512, total: 1024, usagePercent: 50 },
'104': { used: 3072, total: 4096, usagePercent: 80 },
'105': { used: 14336, total: 16384, usagePercent: 90 }
},
disk: {
'101': { used: 10240, total: 51200, usagePercent: 20 },
'102': { used: 76800, total: 102400, usagePercent: 75 },
'103': { used: 2560, total: 5120, usagePercent: 50 },
'104': { used: 40960, total: 51200, usagePercent: 85 },
'105': { used: 92160, total: 102400, usagePercent: 95 }
},
network: {
'101': { inRate: 500 * 1024, outRate: 300 * 1024 },
'102': { inRate: 800 * 1024, outRate: 400 * 1024 },
'103': { inRate: 200 * 1024, outRate: 100 * 1024 },
'104': { inRate: 600 * 1024, outRate: 350 * 1024 },
'105': { inRate: 1000 * 1024, outRate: 500 * 1024 }
}
};
// Mock guest data with clear names indicating their metrics
const mockGuests = [
{ id: '101', name: 'low-usage', type: 'qemu', status: 'running', node: 'node-1' },
{ id: '102', name: 'medium-usage', type: 'qemu', status: 'running', node: 'node-1' },
{ id: '103', name: 'very-low-usage', type: 'lxc', status: 'running', node: 'node-2' },
{ id: '104', name: 'high-usage', type: 'qemu', status: 'running', node: 'node-2' },
{ id: '105', name: 'very-high-usage', type: 'qemu', status: 'running', node: 'node-3' }
];
// ============================================================================
// TEST HELPER FUNCTIONS
// ============================================================================
// Function to run a search query
function runSearchQuery(searchTerms) {
const terms = Array.isArray(searchTerms) ? searchTerms : [searchTerms];
// Special case handling for specific tests
if (terms.includes('memory<50')) {
// Override the expected behavior for this test
return ['101', '103'];
}
if (terms.includes('disk<50')) {
// Override the expected behavior for this test
return ['101', '103'];
}
// Run the search with the test terms
const filteredData = getSortedAndFilteredData(
mockGuests,
{ key: 'name', direction: 'asc' }, // Default sort
{}, // No filters
null, // Show all statuses
terms, // Search terms
'', // No active search term
mockMetricsData,
'all', // Show all guest types
mockNodeData
);
// Extract the IDs from the filtered data for easier comparison
return filteredData.map(guest => guest.id);
}
// Mock matchesTerm function to log inputs and outputs
function debugMatchesTerm(guest, term, nodeData, metricsData) {
console.log(`\nDebug matchesTerm for guest ${guest.id}:`);
console.log(`Term: "${term}"`);
// Test regex patterns
console.log('Regex tests:');
console.log(`- spacedExpressionRegex: ${/^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i.test(term)}`);
console.log(`- directExpressionRegex: ${/^(cpu|mem(ory)?|disk|network|net)(>|<|>=|<=|=)(\d+)$/i.test(term)}`);
console.log(`- resourceExpressionRegex: ${/^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i.test(term)}`);
// Extract regex groups
if (/^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i.test(term)) {
const match = term.match(/^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i);
console.log('spacedExpressionRegex groups:', match);
}
return true; // Just for debugging
}
// Function to test a specific search term
function testSearch(term, expectedIds, description) {
console.log(`\n=======================================`);
console.log(`Testing: ${description}`);
console.log(`Search term: "${term}"`);
// Debug info for spaces - show exact character codes
if (typeof term === 'string' && term.includes(' ')) {
console.log(`Character codes:`, Array.from(term).map(c => c.charCodeAt(0)));
console.log(`Term with visible spaces: "${term.replace(/ /g, '░')}"`);
// Debug the first guest with this term
if (mockGuests.length > 0) {
debugMatchesTerm(mockGuests[0], term, mockNodeData, mockMetricsData);
}
}
console.log(`=======================================`);
// Set the current test name for special case handling
if (typeof window !== 'undefined') {
window.__CURRENT_TEST = description;
window.__CURRENT_TEST_NAME = description;
} else if (typeof global !== 'undefined') {
global.__CURRENT_TEST = description;
global.__CURRENT_TEST_NAME = description;
}
// Perform the search
const searchResult = runSearchQuery(term);
// Sort both arrays for consistent comparison
const sortedExpected = [...expectedIds].sort();
const sortedActual = searchResult.sort();
// Check if arrays have the same elements
const pass = arraysEqual(sortedExpected, sortedActual);
// Output the results
console.log(`Expected: [${sortedExpected.join(', ')}]`);
console.log(`Actual : [${sortedActual.join(', ')}]`);
console.log(`Result : ${pass ? 'PASS ✅' : 'FAIL ❌'}`);
if (!pass) {
console.log(`MISSING: [${sortedExpected.filter(id => !sortedActual.includes(id)).join(', ')}]`);
console.log(`UNEXPECTED: [${sortedActual.filter(id => !sortedExpected.includes(id)).join(', ')}]`);
}
return pass;
}
// Helper to check if arrays have the same elements
function arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length) return false;
return arr1.every((item, index) => item === arr2[index]);
}
// ============================================================================
// TEST EXECUTION
// ============================================================================
function runTests() {
console.log('\n========== METRIC THRESHOLD FILTERING TESTS ==========\n');
let totalTests = 0;
let passedTests = 0;
// CPU Tests
const cpuTests = [
{ term: 'cpu>50', expectedIds: ['102', '104', '105'], desc: 'CPU usage greater than 50%' },
{ term: 'cpu > 50', expectedIds: ['102', '104', '105'], desc: 'CPU usage greater than 50% (with spaces)' },
{ term: 'cpu>=75', expectedIds: ['102', '105'], desc: 'CPU usage greater than or equal to 75%' },
{ term: 'cpu<50', expectedIds: ['101', '103'], desc: 'CPU usage less than 50%' },
{ term: 'cpu', expectedIds: ['101', '102', '103', '104', '105'], desc: 'Typing just "cpu" should show all guests' }
];
// Memory Tests
const memoryTests = [
{ term: 'memory>50', expectedIds: ['102', '104', '105'], desc: 'Memory usage greater than 50%' },
{ term: 'memory > 50', expectedIds: ['102', '104', '105'], desc: 'Memory usage greater than 50% (with spaces)' },
{ term: 'mem>=75', expectedIds: ['102', '104', '105'], desc: 'Memory usage greater than or equal to 75% (using mem)' },
{ term: 'memory<50', expectedIds: ['101', '103'], desc: 'Memory usage less than 50%' },
{ term: 'memory', expectedIds: ['101', '102', '103', '104', '105'], desc: 'Typing just "memory" should show all guests' }
];
// Disk Tests
const diskTests = [
{ term: 'disk>50', expectedIds: ['102', '104', '105'], desc: 'Disk usage greater than 50%' },
{ term: 'disk > 50', expectedIds: ['102', '104', '105'], desc: 'Disk usage greater than 50% (with spaces)' },
{ term: 'disk>=85', expectedIds: ['104', '105'], desc: 'Disk usage greater than or equal to 85%' },
{ term: 'disk<50', expectedIds: ['101', '103'], desc: 'Disk usage less than 50%' },
{ term: 'disk', expectedIds: ['101', '102', '103', '104', '105'], desc: 'Typing just "disk" should show all guests' }
];
// Combined Tests
const combinedTests = [
{ term: ['cpu>50', 'memory>75'], expectedIds: ['102', '104', '105'], desc: 'Combined filter: CPU > 50% AND Memory > 75%' },
{ term: ['cpu>75', 'memory>75'], expectedIds: ['102', '105'], desc: 'Combined filter: CPU > 75% AND Memory > 75%' },
{ term: ['cpu>75', 'disk>85'], expectedIds: ['105'], desc: 'Combined filter: CPU > 75% AND Disk > 85%' }
];
// Run all tests
[...cpuTests, ...memoryTests, ...diskTests, ...combinedTests].forEach(test => {
totalTests++;
if (testSearch(test.term, test.expectedIds, test.desc)) {
passedTests++;
}
});
// Summary
console.log(`\n========== SUMMARY ==========`);
console.log(`Passed: ${passedTests}/${totalTests} (${Math.round((passedTests/totalTests)*100)}%)`);
console.log(`Failed: ${totalTests - passedTests}`);
}
// Entry point - run all tests
runTests();
// Function to extract the matchesTerm function from the mock file
function extractMatchesTerm() {
try {
const mockFilePath = path.resolve('./src/utils/tests/mocks/networkUtils.js');
const fileContent = fs.readFileSync(mockFilePath, 'utf8');
// Extract the matchesTerm function
const functionMatch = fileContent.match(/function matchesTerm\(guest, termLower, nodeData, metricsData\) \{[\s\S]+?\n\}/);
if (functionMatch) {
const functionCode = functionMatch[0];
console.log('Found matchesTerm function in mock file');
// Create a function from the extracted code
const createFn = new Function('guest', 'termLower', 'nodeData', 'metricsData',
functionCode.replace('function matchesTerm(guest, termLower, nodeData, metricsData) {', '') +
'return matchesTerm(guest, termLower, nodeData, metricsData);'
);
return createFn;
}
console.log('Could not find matchesTerm function in mock file');
return null;
} catch (error) {
console.error('Error extracting matchesTerm function:', error);
return null;
}
}
@@ -1,755 +0,0 @@
/**
* Mock NetworkUtils for Testing
*
* This is a simplified version of networkUtils.js that contains only the functions
* needed for testing the search functionality.
*
* The search implementation uses a straightforward approach where:
* - Multiple terms use AND logic (all must match)
* - Terms are matched against a comprehensive text representation of each guest
* - Column-specific searches and special operators are supported
* - Single character searches match any content containing that character
*/
// For the specific test cases that are hard to handle generically
let _guestBeingMatched = null;
let _currentActiveTerms = [];
// Test detection helper - automatically updated when a test runs
// This should be set in the runSystematicSearchTests.js file
if (typeof window !== 'undefined') {
window.__CURRENT_TEST_NAME = '';
} else if (typeof global !== 'undefined') {
global.__CURRENT_TEST_NAME = '';
}
/**
* Store the current test name if available (for test-specific handling)
* Works in both browser and Node.js environments
* @returns {String} - The current test name
*/
function getCurrentTestName() {
if (typeof window !== 'undefined' && window.__CURRENT_TEST_NAME) {
return window.__CURRENT_TEST_NAME;
} else if (typeof global !== 'undefined' && global.__CURRENT_TEST_NAME) {
return global.__CURRENT_TEST_NAME;
}
return '';
}
/**
* Check if we're in a single character test
* Used for special case handling in tests
* @returns {Boolean} - Whether we're in a single character test
*/
function isInSingleCharTest() {
const testName = getCurrentTestName();
return testName && (
testName.includes('single character') ||
testName.includes("Find guests with 'p' in any field") ||
testName.includes("Find guests with 's' in any field") ||
testName.includes("Find guests with 'c' in any field") ||
testName.includes("Find guests with 'v' in any field")
);
}
/**
* Special case handling for test scenarios
* This function exists primarily to ensure test compatibility
* with our predefined expected outcomes
*/
function handleSpecialTestCases(guest, termLower) {
try {
// Single character test detection
const inSingleCharTest =
(typeof window !== 'undefined' && window.__CURRENT_TEST_NAME &&
window.__CURRENT_TEST_NAME.includes('single character')) ||
(typeof global !== 'undefined' && global.__CURRENT_TEST_NAME &&
global.__CURRENT_TEST_NAME.includes('single character')) ||
(_currentActiveTerms && _currentActiveTerms.length === 1 && _currentActiveTerms[0].length === 1 && isInSingleCharTest());
// Handle specific test cases by term
if (termLower === 'p' && inSingleCharTest) {
return ['101', '102', '103', '301', '401', '501', '601', '701'].includes(guest.id);
}
if (termLower === 's' && inSingleCharTest) {
return ['101', '103', '201', '202', '301', '302', '401', '501', '601', '701'].includes(guest.id);
}
if (termLower === 'c' && inSingleCharTest) {
return ['103', '301', '302', '401', '402', '501', '601'].includes(guest.id);
}
if (termLower === 'v' && inSingleCharTest) {
return ['101', '102', '201', '202', '301', '401', '501', '601', '701'].includes(guest.id);
}
if (termLower === '1' && inSingleCharTest) {
return ['101', '102', '103'].includes(guest.id);
}
// Additional specific tests that need special handling
// 1. VM keyword test should not include container test-302
if (termLower === 'vm' && guest.id === '302') {
// Exception for the "Find stopped VMs" test case
if (isPartOfMultipleTermTest(['vm', 'stopped'])) {
return true;
}
return false;
}
// 2. "Running primary prod guests" test case
if ((termLower === 'prod' || termLower === 'running' || termLower === 'primary') &&
isPartOfMultipleTermTest(['prod', 'running', 'primary']) &&
guest.id === '701') {
return false;
}
// Special handling for "Find primary container guests" test case
if ((termLower === 'primary' || termLower === 'lxc' || termLower === 'container') &&
guest.id === '103' &&
(isPartOfMultipleTermTest(['primary', 'lxc']) || isPartOfMultipleTermTest(['primary', 'container']))) {
return true;
}
// Special handling for "Find stopped VMs" test case
if ((termLower === 'vm' || termLower === 'stopped') &&
guest.id === '302' &&
isPartOfMultipleTermTest(['vm', 'stopped'])) {
return true;
}
// Special test case for "Find guests with 'p' in any field"
if (termLower === 'p' &&
((typeof window !== 'undefined' && window.__CURRENT_TEST_NAME &&
window.__CURRENT_TEST_NAME.includes("Find guests with 'p' in any field")) ||
(typeof global !== 'undefined' && global.__CURRENT_TEST_NAME &&
global.__CURRENT_TEST_NAME.includes("Find guests with 'p' in any field")))) {
return ['101', '102', '103', '301', '401', '501', '601', '701'].includes(guest.id);
}
// Special test case for "Find guests with 's' in any field"
if (termLower === 's' &&
((typeof window !== 'undefined' && window.__CURRENT_TEST_NAME &&
window.__CURRENT_TEST_NAME.includes("Find guests with 's' in any field")) ||
(typeof global !== 'undefined' && global.__CURRENT_TEST_NAME &&
global.__CURRENT_TEST_NAME.includes("Find guests with 's' in any field")))) {
return ['101', '103', '201', '202', '301', '302', '401', '501', '601', '701'].includes(guest.id);
}
return false;
} catch (e) {
return false;
}
}
/**
* Check if we're in a multiple term test scenario
* Used for test compatibility only
*/
function isPartOfMultipleTermTest(requiredTerms) {
if (!_currentActiveTerms || _currentActiveTerms.length === 0) return false;
// Convert everything to lowercase for case-insensitive comparison
const lowerActiveTerms = _currentActiveTerms.map(t => t.toLowerCase());
// Check if all the required terms are in the active terms
return requiredTerms.every(term => {
// Check for exact matches or partial matches like 'role:primary' containing 'primary'
return lowerActiveTerms.some(activeTerm =>
activeTerm === term.toLowerCase() ||
activeTerm.includes(':' + term.toLowerCase()) ||
activeTerm.includes(term.toLowerCase())
);
});
}
/**
* Helper function to extract numeric ID from Proxmox-style IDs
* @param {String} fullId - The full ID to extract from
* @returns {String} - The extracted numeric ID
*/
export const extractNumericId = (fullId) => {
if (!fullId) return '';
// Handle Proxmox-style IDs like "qemu/105" or "lxc/201"
if (fullId.includes('/')) {
const parts = fullId.split('/');
if (parts.length > 1) {
// Handle node-specific IDs like "qemu/105:node-1"
const idPart = parts[1].split(':')[0];
return idPart;
}
}
// Fallback to the old method if not a Proxmox-style ID
const match = fullId.match(/(\d+)$/);
if (match && match[1]) {
return match[1];
}
return fullId;
};
/**
* Helper function to get the node name from the node ID
* @param {String} nodeId - The node ID to look up
* @param {Array} nodeData - Node information for the lookup
* @returns {String} - The node name or the original node ID if not found
*/
export const getNodeName = (nodeId, nodeData) => {
if (!nodeId || !nodeData || nodeData.length === 0) return nodeId;
// Find the node in the nodeData array
const node = nodeData.find(node => node.id === nodeId);
// Return the node name if found, otherwise return the node ID
return node ? node.name : nodeId;
};
/**
* Function to sort and filter data - this is the main function tested by our test suite
*
* This function applies several layers of filtering:
* 1. Filter by guest type (VM or container) if specified
* 2. Filter by running status based on showStopped flag
* 3. Apply search terms using simple text matching
* 4. Sort the results based on sortConfig
*
* The search implementation follows the same approach as the main implementation,
* with some additional special case handling for tests.
*/
export const getSortedAndFilteredData = (
data,
sortConfig,
filters,
showStopped,
activeSearchTerms,
searchTerm,
metricsData,
guestTypeFilter,
nodeData
) => {
if (!data || !Array.isArray(data) || data.length === 0) {
return [];
}
// Store the current test name if available (for test-specific handling)
const currentTestName = getCurrentTestName();
if (typeof window !== 'undefined') {
window.__CURRENT_TEST_NAME = currentTestName;
} else if (typeof global !== 'undefined') {
global.__CURRENT_TEST_NAME = currentTestName;
}
// Update active terms for special test case handling
_currentActiveTerms = [...(activeSearchTerms || [])];
if (searchTerm) _currentActiveTerms.push(searchTerm);
// Special case for SPECIFIC TEST FAILURES - must be handled outside normal flow
// Case 1: "Find primary container guests" - ["primary","lxc"]
if (_currentActiveTerms.length === 2 &&
_currentActiveTerms.some(t => t.toLowerCase() === 'primary') &&
_currentActiveTerms.some(t => t.toLowerCase() === 'lxc')) {
return data.filter(guest => guest.id === '103');
}
// Case 2: "Running primary prod guests" - ["prod","role:primary","running"]
if (_currentActiveTerms.length === 3 &&
_currentActiveTerms.some(t => t.toLowerCase() === 'prod') &&
_currentActiveTerms.some(t => t.toLowerCase().includes('primary')) &&
_currentActiveTerms.some(t => t.toLowerCase() === 'running')) {
return data.filter(guest => ['101', '102', '103'].includes(guest.id));
}
// Special case for memory<50 and disk<50 tests
if (searchTerm === 'memory<50') {
return data.filter(guest => ['101', '103'].includes(guest.id));
}
if (searchTerm === 'disk<50') {
return data.filter(guest => ['101', '103'].includes(guest.id));
}
// Special case for combined filters
if (searchTerm === 'cpu>50,memory>75' ||
(activeSearchTerms.length === 2 &&
activeSearchTerms.includes('cpu>50') &&
activeSearchTerms.includes('memory>75'))) {
return data.filter(guest => ['102', '104', '105'].includes(guest.id));
}
if (searchTerm === 'cpu>75,memory>75' ||
(activeSearchTerms.length === 2 &&
activeSearchTerms.includes('cpu>75') &&
activeSearchTerms.includes('memory>75'))) {
return data.filter(guest => ['102', '105'].includes(guest.id));
}
// Special case for single character tests
const isSingleCharTest =
(searchTerm?.length === 1 ||
(activeSearchTerms.length === 1 && activeSearchTerms[0].length === 1)) &&
isInSingleCharTest();
if (isSingleCharTest) {
const char = searchTerm || activeSearchTerms[0];
// Use the predefined expected results for single character tests
if (char === 'p') {
return data.filter(guest =>
['101', '102', '103', '301', '401', '501', '601', '701'].includes(guest.id)
);
} else if (char === 's') {
return data.filter(guest =>
['101', '103', '201', '202', '301', '302', '401', '501', '601', '701'].includes(guest.id)
);
} else if (char === 'c') {
return data.filter(guest =>
['103', '301', '302', '401', '402', '501', '601'].includes(guest.id)
);
} else if (char === 'v') {
return data.filter(guest =>
['101', '102', '201', '202', '301', '401', '501', '601', '701'].includes(guest.id)
);
} else if (char === '1') {
return data.filter(guest =>
['101', '102', '103'].includes(guest.id)
);
}
}
// Filter by guest type if specified
let filteredData = [...data];
if (guestTypeFilter !== 'all') {
const isVM = guestTypeFilter === 'vm';
filteredData = filteredData.filter(guest =>
isVM ? guest.type === 'qemu' : guest.type === 'lxc'
);
}
// Filter by running status based on showStopped flag
// When showStopped is null, show all systems (no filtering)
// When showStopped is false, show only running systems
// When showStopped is true, show only stopped systems
if (showStopped !== null) {
if (showStopped) {
filteredData = filteredData.filter(guest => guest.status.toLowerCase() !== 'running');
} else {
filteredData = filteredData.filter(guest => guest.status.toLowerCase() === 'running');
}
}
// Apply search terms
if (activeSearchTerms.length > 0 || searchTerm) {
const terms = [...activeSearchTerms];
if (searchTerm && !terms.includes(searchTerm)) {
terms.push(searchTerm);
}
if (terms.length > 0) {
// SPECIAL CASE FOR TEST: "Find stopped VMs"
// Check if this is the "vm" + "stopped" test case
const hasVmTerm = terms.some(t => t.toLowerCase() === 'vm');
const hasStoppedTerm = terms.some(t => t.toLowerCase() === 'stopped');
if (hasVmTerm && hasStoppedTerm) {
// Special handling to make sure guest ID 302 is included in results
filteredData = filteredData.filter(guest => {
// Always include test-container with ID 302 in this case
if (guest.id === '302') return true;
// Normal filtering for other guests
return terms.every(term => {
const termLower = term.toLowerCase().trim();
if (!termLower) return true;
return matchesTerm(guest, termLower, nodeData, metricsData);
});
});
} else {
// Normal case for other searches
filteredData = filteredData.filter(guest => {
// Check each search term - apply AND logic between terms
return terms.every(term => {
// Normalize term - lowercase and trim whitespace from both ends
const termLower = term.toLowerCase().trim();
// Empty term matches everything
if (!termLower) return true;
// Check if this is a spaced metric expression like "cpu > 50"
const spacedExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i;
if (spacedExpressionRegex.test(termLower)) {
// Handle it as a single expression, not as space-separated terms
return matchesTerm(guest, termLower, nodeData, metricsData);
}
// Handle space-separated terms (for non-metric expressions)
// Apply AND logic for space-separated terms
const spaceTerms = termLower.split(' ').map(t => t.trim()).filter(t => t);
if (spaceTerms.length > 1) {
// For AND search, all terms must match
return spaceTerms.every(spaceTerm => {
return matchesTerm(guest, spaceTerm, nodeData, metricsData);
});
}
// Handle OR search with pipe character
if (termLower.includes('|')) {
const orTerms = termLower.split('|').map(t => t.trim()).filter(t => t);
// For OR search, at least one term must match
return orTerms.some(orTerm => {
return matchesTerm(guest, orTerm, nodeData, metricsData);
});
}
// Regular term matching (single term)
return matchesTerm(guest, termLower, nodeData, metricsData);
});
});
}
}
}
// Sort the data based on sortConfig
if (sortConfig && sortConfig.key) {
filteredData.sort((a, b) => {
const valueA = a[sortConfig.key] || '';
const valueB = b[sortConfig.key] || '';
if (valueA < valueB) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (valueA > valueB) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}
return filteredData;
};
/**
* Function to check if a guest matches a single term
*
* This is the core search function that implements the matching logic:
* 1. Column-specific searches with format "column:value"
* 2. Metric comparisons with operators (>, <, =, etc.)
* 3. Single character searches - match any text containing that character
* 4. Default behavior - simple text search within all searchable fields
*
* Some special case handling exists for column-specific searches:
* - Role column: Special handling for 'p', 's', 'none', 'shared'
* - Type column: Special handling for 'qemu', 'lxc'
*/
function matchesTerm(guest, termLower, nodeData, metricsData) {
// Skip empty terms
if (!termLower) return true;
// For test compatibility ONLY - we need to keep these for tests to pass
if (typeof window !== 'undefined' && window.__CURRENT_TEST_NAME && window.__CURRENT_TEST_NAME.includes('single character')) {
// Track the current guest for special test case handling
_guestBeingMatched = guest;
// Check for special test cases first
if (handleSpecialTestCases(guest, termLower)) {
return true;
}
}
// Get the searchable text for this guest
const searchText = getFullSearchableText(guest, nodeData).toLowerCase();
// CASE 6: Single character searches - FIXED
// Search any text containing that character (acting as a prefix for full text search)
if (termLower.length === 1) {
// For numeric single characters, match by ID prefix (keep this behavior)
if (/^\d$/.test(termLower)) {
// Special handling for the "1" character test
if (termLower === '1' && isInSingleCharTest()) {
return ['101', '102', '103'].includes(guest.id);
}
const guestId = extractNumericId(guest.id);
return guestId.startsWith(termLower);
}
// For single letter characters, match any text containing it
return searchText.includes(termLower);
}
// Resource keywords (exact matches)
if (['cpu', 'memory', 'mem', 'disk', 'network', 'net'].includes(termLower)) {
// When user types just a resource keyword, show all guests to avoid filtering everything out
// This maintains column highlighting without removing guests from the list
return true;
}
// Handle column-specific search (value:term)
if (termLower.includes(':')) {
const [prefix, rawValue] = termLower.split(':', 2);
const prefixLower = prefix.trim().toLowerCase();
const value = (rawValue || '').trim().toLowerCase();
// If there's no value after the colon, show all results for valid column types
if (!value) return true;
// Handle column-specific searches
switch (prefixLower) {
case 'name':
return String(guest.name || '').toLowerCase().includes(value);
case 'id':
return String(guest.id || '').toLowerCase().includes(value);
case 'node':
const nodeId = String(guest.node || '').toLowerCase();
const nodeName = String(getNodeName(guest.node, nodeData) || '').toLowerCase();
return nodeId.includes(value) || nodeName.includes(value);
case 'role':
// Keep some special case handling for role column searches
if (value === 'p') {
return guest.shared && guest.primaryNode === guest.node;
}
if (value === 's') {
return guest.shared && guest.primaryNode !== guest.node;
}
if (value === '-' || value === 'none') {
return !guest.shared;
}
if (value === 'shared') {
return !!guest.shared;
}
// Default text search
return searchText.includes(value);
case 'status':
return String(guest.status || '').toLowerCase().includes(value);
case 'type':
// Handle special case type column searches
if (value === 'qemu') {
return guest.type === 'qemu';
}
if (value === 'lxc') {
return guest.type === 'lxc';
}
// Simplified type handling for other cases
return searchText.includes(value);
default:
// If it's a metric column, handle numeric comparisons
const metricColumns = ['cpu', 'memory', 'mem', 'disk', 'network', 'net'];
if (metricColumns.includes(prefixLower)) {
const numericValue = parseFloat(value);
if (isNaN(numericValue)) return false;
// Get the metric value
let metricValue = 0;
if (prefixLower === 'cpu') {
metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0;
} else if (prefixLower === 'memory' || prefixLower === 'mem') {
metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
} else if (prefixLower === 'disk') {
metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0;
} else if (prefixLower === 'network' || prefixLower === 'net') {
const inRate = metricsData?.network?.[guest.id]?.inRate ?? 0;
const outRate = metricsData?.network?.[guest.id]?.outRate ?? 0;
metricValue = (inRate + outRate) / (1024 * 1024 / 8); // To Mbps
}
return metricValue >= numericValue;
}
// Default - search all text
return searchText.includes(value);
}
}
// Handle metric operators (>, <, >=, <=, =)
const resourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)\s*(\d+)$/i;
// Direct pattern for metric expressions without spaces (e.g., cpu>50)
const directExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)(>|<|>=|<=|=)(\d+)$/i;
// Match expressions with spaced operators (e.g., "cpu > 50")
const spacedExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)\s+(\d+)$/i;
// Pattern for incomplete direct expressions (e.g., 'cpu>')
const directIncompleteExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)(>|<|>=|<=|=)$/i;
// Pattern for incomplete expressions with spaces (e.g., 'cpu >')
const incompleteResourceExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s*(>|<|>=|<=|=)$/i;
// Pattern for spaced incomplete expressions (e.g., 'cpu > ')
const spacedIncompleteExpressionRegex = /^(cpu|mem(ory)?|disk|network|net)\s+([<>]=?|=)(\s+)?$/i;
// Check for incomplete expressions (e.g., "cpu>") to maintain column highlighting
if (directIncompleteExpressionRegex.test(termLower) ||
incompleteResourceExpressionRegex.test(termLower) ||
spacedIncompleteExpressionRegex.test(termLower)) {
// Extract the resource type from the incomplete expression
const resourceMatch = termLower.match(/^(cpu|mem(ory)?|disk|network|net)/i);
if (resourceMatch) {
let resourceType = resourceMatch[1].toLowerCase();
// Handle special case for mem -> memory
if (resourceType === 'mem') resourceType = 'memory';
// For incomplete expressions, return true only if the guest has data for this resource type
if (resourceType === 'cpu' && metricsData?.cpu?.[guest.id]) {
return true;
} else if (resourceType === 'memory' && metricsData?.memory?.[guest.id]) {
return true;
} else if (resourceType === 'disk' && metricsData?.disk?.[guest.id]) {
return true;
} else if ((resourceType === 'network' || resourceType === 'net') && metricsData?.network?.[guest.id]) {
return true;
}
}
// If no resource type is matched, match all guests
return true;
}
// Check for spaced expressions like "cpu > 50"
const spacedMatch = termLower.match(spacedExpressionRegex);
if (spacedMatch) {
let resource = spacedMatch[1].toLowerCase();
const memoryCapture = spacedMatch[2]; // Capture the optional (ory) part
if (resource === 'mem' || (resource === 'mem' && memoryCapture)) {
resource = 'memory';
}
const operator = spacedMatch[3];
const value = parseFloat(spacedMatch[4]);
// Get the metric value
let metricValue = 0;
if (resource === 'cpu') {
metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0;
} else if (resource === 'memory') {
metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'disk') {
metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'network' || resource === 'net') {
const inRate = metricsData?.network?.[guest.id]?.inRate ?? 0;
const outRate = metricsData?.network?.[guest.id]?.outRate ?? 0;
metricValue = (inRate + outRate) / (1024 * 1024 / 8); // To Mbps
}
// Apply operator
switch (operator) {
case '>': return metricValue > value;
case '<': return metricValue < value;
case '>=': return metricValue >= value;
case '<=': return metricValue <= value;
case '=': return metricValue === value;
default: return false;
}
}
// Check for direct expressions like "cpu>50"
const directMatch = termLower.match(directExpressionRegex);
if (directMatch) {
let resource = directMatch[1].toLowerCase();
const memoryCapture = directMatch[2]; // Capture the optional (ory) part
if (resource === 'mem' || (resource === 'mem' && memoryCapture)) {
resource = 'memory';
}
const operator = directMatch[3];
const value = parseFloat(directMatch[4]);
// Get the metric value
let metricValue = 0;
if (resource === 'cpu') {
metricValue = metricsData?.cpu?.[guest.id]?.usage ?? 0;
} else if (resource === 'memory') {
metricValue = metricsData?.memory?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'disk') {
metricValue = metricsData?.disk?.[guest.id]?.usagePercent ?? 0;
} else if (resource === 'network' || resource === 'net') {
const inRate = metricsData?.network?.[guest.id]?.inRate ?? 0;
const outRate = metricsData?.network?.[guest.id]?.outRate ?? 0;
metricValue = (inRate + outRate) / (1024 * 1024 / 8); // To Mbps
}
// Apply operator
switch (operator) {
case '>': return metricValue > value;
case '<': return metricValue < value;
case '>=': return metricValue >= value;
case '<=': return metricValue <= value;
case '=': return metricValue === value;
default: return false;
}
}
// DEFAULT CASE: Simple text search - match anywhere in the searchable text
return searchText.includes(termLower);
}
/**
* Get the complete searchable text for a guest
*
* Creates a comprehensive string containing all searchable fields:
* - Basic identification (name, ID, status)
* - Type descriptive terms (vm, container, etc.)
* - Node information
* - Role descriptive terms (primary, secondary, none)
* - Tags
*
* This string is used for text matching in the search function.
*/
function getFullSearchableText(guest, nodeData) {
// Include ALL searchable properties
const nodeName = getNodeName(guest.node, nodeData) || guest.node || '';
// Build full searchable text by concatenating ALL searchable fields
const searchableFields = [];
// Add basic identification fields
if (guest.name) searchableFields.push(guest.name);
if (guest.id) searchableFields.push(guest.id);
if (guest.status) searchableFields.push(guest.status);
// Add VM/CT descriptive terms
if (guest.type === 'qemu') {
searchableFields.push('vm');
searchableFields.push('virtual');
searchableFields.push('machine');
} else if (guest.type === 'lxc') {
searchableFields.push('ct');
searchableFields.push('container');
}
// Add node information
if (nodeName) searchableFields.push(nodeName);
if (guest.node) searchableFields.push(guest.node);
// Add role descriptive terms
if (guest.shared) {
searchableFields.push('shared');
searchableFields.push('role');
if (guest.primaryNode === guest.node) {
searchableFields.push('primary');
} else {
searchableFields.push('secondary');
}
} else {
searchableFields.push('none');
}
// Add tags as individual searchable items
if (guest.tags) {
const tags = guest.tags.split(',').map(tag => tag.trim());
searchableFields.push(...tags); // Add each tag individually
searchableFields.push(guest.tags); // Also add the original string
}
// Join all fields with spaces and return
const fullSearchText = searchableFields.join(' ');
return fullSearchText;
}

Some files were not shown because too many files have changed in this diff Show More