From b316510a2bcc7e1bb6ebe2b4e1707f44cc29ae29 Mon Sep 17 00:00:00 2001
From: Pulse Monitor
Date: Sat, 16 Aug 2025 12:12:10 +0000
Subject: [PATCH] fix: resolve WebSocket metric updates and improve polling
efficiency
- Fix alternating zero I/O metrics by implementing rate caching for stale data from Proxmox
- Hardcode polling interval to 10 seconds (matching Proxmox cluster/resources update cycle)
- Remove polling interval settings from UI (no longer user-configurable)
- Implement efficient VM/container polling using single cluster/resources API call
- Remove 'Remove Password' feature (auth is now mandatory)
- Fix CSRF validation for Basic Auth (exempt from CSRF checks)
- Fix Generate API Token modal and authentication
- Remove redundant 'Active' status from Authentication section
- Remove Connection Timeout setting from frontend (backend-only)
- Clean up frontend console logging (reduce verbosity)
- Remove PBS polling interval setting (fixed at 10s)
- Add frontend rebuild detection to backend-watch script
- Improve first-run setup flow and error handling
---
PROXMOX_ENDPOINTS.md | 68 +++
README.md | 12 +-
cmd/pulse/main.go | 75 ++-
docs/CONFIGURATION.md | 428 +++++----------
frontend-modern/src/api/settings.ts | 23 +-
.../src/components/Dashboard/IOMetric.tsx | 34 +-
.../src/components/Dashboard/NodeCard.tsx | 24 +-
.../src/components/FirstRunSetup.tsx | 486 ++++++++++++++++++
.../src/components/Settings/APIOnlySetup.tsx | 125 +++++
.../components/Settings/GenerateAPIToken.tsx | 105 ++--
.../src/components/Settings/NodeModal.tsx | 4 +-
.../Settings/QuickSecuritySetup.tsx | 44 +-
.../Settings/RemovePasswordModal.tsx | 145 ------
.../src/components/Settings/Settings.tsx | 212 +++-----
.../src/components/shared/AnimatedMetric.tsx | 80 +++
frontend-modern/src/index.tsx | 1 +
frontend-modern/src/stores/websocket.ts | 9 +-
frontend-modern/src/styles/animations.css | 193 +++++++
frontend-modern/src/types/config.ts | 142 +++++
frontend-modern/src/utils/logger.ts | 7 +-
go.mod | 1 +
go.sum | 2 +
hot-dev.sh | 5 +-
internal/api/auth.go | 68 ++-
internal/api/config_handlers.go | 53 +-
internal/api/router.go | 103 +---
internal/api/security.go | 5 +
internal/api/security_setup_fix.go | 115 +++--
internal/config/config.go | 145 +++---
internal/config/persistence.go | 12 +-
internal/config/watcher.go | 218 ++++++++
internal/monitoring/monitor.go | 192 ++++++-
internal/monitoring/ratetracker.go | 52 +-
internal/monitoring/reload.go | 28 +
pkg/proxmox/client.go | 92 +++-
pkg/proxmox/cluster_client.go | 28 +
pkg/tlsutil/fingerprint.go | 6 +
scripts/remove-password.sh | 28 -
test-60s.sh | 30 ++
test-all-endpoints.sh | 50 ++
test-api.sh | 7 +
test-nodes-endpoint.py | 84 +++
test-proxmox-fast.py | 78 +++
test-proxmox-polling.py | 126 +++++
test-raw-curl.sh | 49 ++
45 files changed, 2847 insertions(+), 947 deletions(-)
create mode 100644 PROXMOX_ENDPOINTS.md
create mode 100644 frontend-modern/src/components/FirstRunSetup.tsx
create mode 100644 frontend-modern/src/components/Settings/APIOnlySetup.tsx
delete mode 100644 frontend-modern/src/components/Settings/RemovePasswordModal.tsx
create mode 100644 frontend-modern/src/components/shared/AnimatedMetric.tsx
create mode 100644 frontend-modern/src/styles/animations.css
create mode 100644 frontend-modern/src/types/config.ts
create mode 100644 internal/config/watcher.go
delete mode 100755 scripts/remove-password.sh
create mode 100755 test-60s.sh
create mode 100755 test-all-endpoints.sh
create mode 100755 test-api.sh
create mode 100644 test-nodes-endpoint.py
create mode 100644 test-proxmox-fast.py
create mode 100755 test-proxmox-polling.py
create mode 100755 test-raw-curl.sh
diff --git a/PROXMOX_ENDPOINTS.md b/PROXMOX_ENDPOINTS.md
new file mode 100644
index 000000000..d1d260451
--- /dev/null
+++ b/PROXMOX_ENDPOINTS.md
@@ -0,0 +1,68 @@
+# Proxmox API Endpoint Documentation
+
+## Update Frequencies and Use Cases
+
+Based on empirical testing against Proxmox VE, here's what each endpoint provides:
+
+### Node Metrics
+
+| Endpoint | Update Frequency | Use Case | Data Freshness |
+|----------|-----------------|----------|----------------|
+| `/nodes` | ~10 seconds | Node list, basic status | Cached/aggregated |
+| `/nodes/{node}/status` | **1 second** | Real-time node metrics | Real-time |
+| `/cluster/resources?type=node` | ~10 seconds | Cluster overview | Cached |
+
+### VM/Container Metrics
+
+| Endpoint | Update Frequency | Use Case | Data Freshness |
+|----------|-----------------|----------|----------------|
+| `/nodes/{node}/qemu` | On state change | VM list | Current |
+| `/nodes/{node}/qemu/{vmid}/status/current` | **1 second** | Real-time VM metrics | Real-time |
+| `/nodes/{node}/lxc` | On state change | Container list | Current |
+| `/nodes/{node}/lxc/{vmid}/status/current` | **1 second** | Real-time container metrics | Real-time |
+
+### Storage & Backup
+
+| Endpoint | Update Frequency | Use Case | Data Freshness |
+|----------|-----------------|----------|----------------|
+| `/nodes/{node}/storage` | ~30 seconds | Storage overview | Cached |
+| `/nodes/{node}/storage/{storage}/content` | On change | Backup listings | Current |
+| `/nodes/{node}/tasks` | On change | Task status | Current |
+
+## Key Findings
+
+1. **Real-time endpoints** (`/status` and `/status/current`) update every second
+2. **List endpoints** (`/nodes`, `/qemu`, `/lxc`) are cached/aggregated
+3. **pvestatd** updates different endpoints at different rates
+4. The commonly cited "10 second update interval" only applies to aggregated endpoints
+
+## Recommended Polling Strategy
+
+For real-time monitoring:
+- **Node metrics**: Poll `/nodes/{node}/status` every 1-2 seconds
+- **VM/Container metrics**: Poll `/status/current` endpoints every 1-2 seconds
+- **Storage**: Poll every 30-60 seconds (changes less frequently)
+- **Backup tasks**: Poll every 30-60 seconds or on-demand
+
+## Test Results
+
+### Test 1: /nodes endpoint
+- Update interval: ~10 seconds
+- Shows aggregated data across cluster
+
+### Test 2: /nodes/{node}/status endpoint
+- Update interval: **1 second**
+- Provides real-time CPU, memory, disk, uptime
+- This is the endpoint to use for live monitoring
+
+### Test 3: /cluster/resources endpoint
+- Update interval: ~10 seconds
+- Similar to /nodes but includes VMs/containers
+
+## Implementation Notes
+
+Current Pulse implementation uses:
+- `GetNodes()` - Uses `/nodes` (slow, cached)
+- `GetNodeStatus()` - Uses `/nodes/{node}/status` (real-time)
+
+We should prioritize GetNodeStatus() data over GetNodes() data for metrics that need to be real-time.
\ No newline at end of file
diff --git a/README.md b/README.md
index dae40acee..03a8cf8ad 100644
--- a/README.md
+++ b/README.md
@@ -165,9 +165,19 @@ See [Security Documentation](docs/SECURITY.md) for details.
Quick start - most settings are in the web UI:
- **Settings → Nodes**: Add/remove Proxmox instances
-- **Settings → System**: Polling intervals, CORS settings
+- **Settings → System**: Polling intervals, timeouts, update settings
+- **Settings → Security**: Authentication and API tokens
- **Alerts**: Thresholds and notifications
+### Configuration Files
+
+Pulse uses three separate configuration files with clear separation of concerns:
+- `.env` - Authentication credentials only
+- `system.json` - Application settings
+- `nodes.enc` - Encrypted node credentials
+
+See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for detailed documentation on configuration structure and management.
+
### Email Alerts Configuration
Configure email notifications in **Settings → Alerts → Email Destinations**
diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go
index 4eba2e3a1..f168fd2c5 100644
--- a/cmd/pulse/main.go
+++ b/cmd/pulse/main.go
@@ -104,6 +104,17 @@ func runServer() {
IdleTimeout: 60 * time.Second,
}
+ // Start config watcher for .env file changes
+ configWatcher, err := config.NewConfigWatcher(cfg)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to create config watcher, .env changes will require restart")
+ } else {
+ if err := configWatcher.Start(); err != nil {
+ log.Warn().Err(err).Msg("Failed to start config watcher")
+ }
+ defer configWatcher.Stop()
+ }
+
// Start server
go func() {
log.Info().
@@ -115,12 +126,63 @@ func runServer() {
}
}()
- // Wait for interrupt signal
+ // Setup signal handlers
sigChan := make(chan os.Signal, 1)
+ reloadChan := make(chan os.Signal, 1)
+
+ // SIGTERM and SIGINT for shutdown
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
- <-sigChan
-
- log.Info().Msg("Shutting down server...")
+ // SIGHUP for config reload
+ signal.Notify(reloadChan, syscall.SIGHUP)
+
+ // Handle signals
+ for {
+ select {
+ case <-reloadChan:
+ log.Info().Msg("Received SIGHUP, reloading configuration...")
+
+ // Reload .env manually (watcher will also pick it up)
+ if configWatcher != nil {
+ configWatcher.ReloadConfig()
+ }
+
+ // Reload system.json
+ persistence := config.NewConfigPersistence(cfg.DataPath)
+ if persistence != nil {
+ if sysConfig, err := persistence.LoadSystemSettings(); err == nil {
+ // Update polling interval if changed
+ if sysConfig.PollingInterval > 0 {
+ oldInterval := cfg.PollingInterval
+ cfg.PollingInterval = time.Duration(sysConfig.PollingInterval) * time.Second
+ if cfg.PollingInterval != oldInterval {
+ log.Info().
+ Dur("old", oldInterval).
+ Dur("new", cfg.PollingInterval).
+ Msg("Polling interval updated")
+ // Update monitor's polling interval
+ if reloadableMonitor != nil {
+ reloadableMonitor.UpdatePollingInterval(cfg.PollingInterval)
+ }
+ }
+ }
+ // Could reload other system.json settings here
+ log.Info().Msg("Reloaded system configuration")
+ } else {
+ log.Error().Err(err).Msg("Failed to reload system.json")
+ }
+ }
+
+ // Could reload other configs here (alerts.json, webhooks.json, etc.)
+
+ log.Info().Msg("Configuration reload complete")
+
+ case <-sigChan:
+ log.Info().Msg("Shutting down server...")
+ goto shutdown
+ }
+ }
+
+shutdown:
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -133,6 +195,11 @@ func runServer() {
// Stop monitoring
cancel()
reloadableMonitor.Stop()
+
+ // Stop config watcher
+ if configWatcher != nil {
+ configWatcher.Stop()
+ }
log.Info().Msg("Server stopped")
}
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index f32d89796..3a928d803 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -1,338 +1,144 @@
# Pulse Configuration Guide
-## Configuration Methods by Deployment Type
+## Configuration File Structure
-### Docker Deployments
-
-**Configuration location:** `/data` (volume mount)
-- All settings stored in the mounted volume
-- Environment variables passed with `-e` flag or via `/data/.env` file
-- The security wizard creates `/data/.env` for auth credentials
-- Configuration persists in the volume across container restarts
-
-**Setting environment variables:**
-```bash
-# Direct run
-docker run -d \
- -e FRONTEND_PORT=8080 \
- -e UPDATE_CHANNEL=rc \
- -e API_TOKEN=your-secure-token \
- -v pulse_data:/data \
- rcourtman/pulse:latest
-
-# Or use docker-compose.yml (see README)
-```
-
-### LXC/Systemd Deployments (Native Install)
-
-**Configuration location:** `/etc/pulse`
-- Settings stored in encrypted JSON files
-- Environment variables can be set via systemd or .env file
-- .env file at `/etc/pulse/.env` is auto-loaded if present
-- **Service name**: `pulse` (ProxmoxVE) or `pulse-backend` (manual install)
-
-**Setting environment variables - Option 1: Systemd override**
-```bash
-# Edit service (check which exists: pulse or pulse-backend)
-sudo systemctl edit pulse # or pulse-backend
-
-# Add overrides:
-[Service]
-Environment="FRONTEND_PORT=8080"
-Environment="UPDATE_CHANNEL=rc"
-```
-
-**Setting environment variables - Option 2: .env file**
-```bash
-# Create/edit .env file
-sudo nano /etc/pulse/.env
-
-# Add variables:
-FRONTEND_PORT=8080
-UPDATE_CHANNEL=rc
-
-# Restart service
-sudo systemctl restart pulse # or pulse-backend
-```
-
-### Web UI Configuration (Both Deployments)
-Most settings are configured through the web interface at `http://:7655/settings`:
-
-- **Nodes**: Auto-discovery, one-click setup scripts, cluster detection
-- **Alerts**: Thresholds and notification rules
-- **Updates**: Update channels and deployment-specific update instructions
-- **Security**: Export/import encrypted configurations
-
-## Understanding .env vs .enc Files
-
-Pulse uses two different file types for configuration, each serving a specific purpose:
-
-### .env Files (Authentication Only)
-- **Purpose**: Store authentication credentials (username, password, API token)
-- **Format**: Plain text environment variables with hashed values
-- **Location**: `/data/.env` (Docker) or `/etc/pulse/.env` (native)
-- **When used**: Loaded at startup before encryption keys are available
-- **Contents**: Only auth-related variables (PULSE_AUTH_USER, PULSE_AUTH_PASS, API_TOKEN)
-- **Security**: Passwords and tokens are bcrypt-hashed, not plaintext
-
-**Example .env file:**
-```bash
-PULSE_AUTH_USER='admin'
-PULSE_AUTH_PASS='$2a$12$YTZXOCEylj4TaevZ0DCeI.notayQZ..b0OZ97lUZ.Q24fljLiMQHK'
-API_TOKEN='e6e9fcfb4662d2b485000cc5faf2f7e5d8b75e0492b4877c36dadb085f12e57b'
-```
-
-**⚠️ CRITICAL for Docker Compose users:**
-- In docker-compose.yml, you MUST escape `$` as `$$`
-- Example: `PULSE_AUTH_PASS='$$2a$$12$$YTZXOCEylj4TaevZ0DCeI....'`
-- Or use a separate .env file where no escaping is needed
-- The bcrypt hash MUST be exactly 60 characters and enclosed in single quotes!
-
-### .enc Files (Sensitive Configuration)
-- **Purpose**: Store sensitive configuration like Proxmox node credentials
-- **Format**: Encrypted JSON using AES-256-GCM
-- **Location**: `/data/*.enc` (Docker) or `/etc/pulse/*.enc` (native)
-- **When used**: After authentication, requires encryption key
-- **Contents**: Node credentials (tokens, passwords), email settings, webhooks
-- **Security**: Fully encrypted at rest, decrypted only in memory
-
-### Why Both?
-This split architecture exists because:
-1. **Authentication must work before encryption** - You need to authenticate to access the encryption key
-2. **Docker persistence** - Containers need auth to persist across restarts
-3. **Security layers** - Node credentials (the highest risk) get maximum protection in .enc files
-4. **Simplicity** - Auth can be managed with standard environment variables
-
-## Environment Variables
-
-**Available variables:**
-
-Variables that ALWAYS override UI settings:
-- `FRONTEND_PORT` or `PORT` - Web UI port (default: 7655)
-- `API_TOKEN` - Token for API authentication (overrides UI)
-- `PULSE_AUTH_USER` - Username for web UI authentication (overrides UI)
-- `PULSE_AUTH_PASS` - Bcrypt password hash - MUST be 60 chars in single quotes! (overrides UI)
-- `UPDATE_CHANNEL` - stable or rc (overrides UI)
-- `CONNECTION_TIMEOUT` - Connection timeout in seconds (overrides UI)
-- `ALLOWED_ORIGINS` - CORS origins (overrides UI, default: empty = same-origin only)
-- `LOG_LEVEL` - debug/info/warn/error (overrides UI)
-
-Variables that only work if no system.json exists:
-- `POLLING_INTERVAL` - Node check interval in seconds (default: 3)
-
-Other variables:
-- `DISCOVERY_SUBNET` - Network subnet for auto-discovery (default: auto-detect)
-- `ALLOW_UNPROTECTED_EXPORT` - Allow export without auth (default: false)
-- `PULSE_DEV` - Enable development mode features (default: false)
-
-### 3. Secure Environment Variables
-For sensitive data like API tokens and passwords:
-
-```bash
-# Edit systemd service
-sudo systemctl edit pulse-backend
-
-# Add secure environment variables:
-[Service]
-Environment="API_TOKEN=your-secure-token"
-Environment="ALLOW_UNPROTECTED_EXPORT=true"
-
-# Restart service
-sudo systemctl restart pulse # or pulse-backend
-```
-
-**Docker users:**
-```bash
-docker run -e API_TOKEN=secure-token -p 7655:7655 rcourtman/pulse:latest
-```
-
-## Data Storage
-
-### Encrypted Storage
-All sensitive data is automatically encrypted at rest using AES-256-GCM:
-- Node passwords and API tokens
-- Email server passwords
-- PBS credentials
-
-The encryption key is auto-generated and stored in the data directory with restricted permissions.
+Pulse uses three separate configuration files, each with a specific purpose. This separation ensures security, clarity, and proper access control.
### File Locations
+All configuration files are stored in `/etc/pulse/` (or `/data/` in Docker containers).
-**Docker Container:**
-- Base directory: `/data` (mounted volume)
-- Config files: `/data/*.json`, `/data/*.enc`
-- Encryption key: `/data/.encryption.key`
-- Auth config: `/data/.env` (created by security wizard)
-- Metrics: `/data/metrics/`
-- Logs: Container logs (`docker logs pulse`)
-
-**LXC/Native Install:**
-- Base directory: `/etc/pulse`
-- Config files: `/etc/pulse/*.json`, `/etc/pulse/*.enc`
-- Encryption key: `/etc/pulse/.encryption.key`
-- Metrics: `/etc/pulse/metrics/`
-- Logs: `/etc/pulse/pulse.log` or journalctl
-- Optional: `/etc/pulse/.env` for env overrides
-
-**Files created (both deployments):**
-- `system.json` - UI-managed settings
-- `.encryption.key` - Auto-generated encryption key (do not share!)
-- `nodes.enc` - Encrypted node credentials
-- `email.enc` - Encrypted email settings
-
-## Common Configuration Tasks
-
-### Change the Web Port
-
-**Docker:**
-```bash
-# Stop existing container
-docker stop pulse
-
-# Run with new port
-docker run -d --name pulse \
- -e FRONTEND_PORT=8080 \
- -p 8080:8080 \
- -v pulse_data:/data \
- rcourtman/pulse:latest
+```
+/etc/pulse/
+├── .env # Authentication credentials
+├── system.json # Application settings
+└── nodes.enc # Encrypted node credentials
```
-**LXC/Systemd:**
+---
+
+## 📁 `.env` - Authentication & Security
+
+**Purpose:** Contains authentication credentials and security settings ONLY.
+
+**Format:** Environment variables (KEY=VALUE)
+
+**Contents:**
```bash
-echo "FRONTEND_PORT=8080" >> /etc/pulse/.env
-sudo systemctl restart pulse # or pulse-backend
+# User authentication
+PULSE_AUTH_USER='admin' # Admin username
+PULSE_AUTH_PASS='$2a$12$...' # Bcrypt hashed password (keep quotes!)
+API_TOKEN=abc123... # API authentication token
+
+# Security settings
+ENABLE_AUDIT_LOG=true # Enable security audit logging
```
-### Enable API Authentication
-```bash
-sudo systemctl edit pulse-backend
-# Add: Environment="API_TOKEN=your-secure-token"
-sudo systemctl restart pulse # or pulse-backend
+**Important Notes:**
+- Password hash MUST be in single quotes to prevent shell expansion
+- This file should have restricted permissions (600)
+- Never commit this file to version control
+- ProxmoxVE installations may pre-configure API_TOKEN
+
+---
+
+## 📁 `system.json` - Application Settings
+
+**Purpose:** Contains all application behavior settings and configuration.
+
+**Format:** JSON
+
+**Contents:**
+```json
+{
+ "pollingInterval": 5, // Seconds between node polls (2-60)
+ "connectionTimeout": 10, // Seconds before node connection timeout
+ "autoUpdateEnabled": false, // Enable automatic updates
+ "updateChannel": "stable", // Update channel: stable, rc, beta
+ "autoUpdateTime": "03:00", // Time for automatic updates (24hr format)
+ "allowedOrigins": "", // CORS allowed origins (empty = same-origin only)
+ "backendPort": 7655, // Backend API port
+ "frontendPort": 7655 // Frontend UI port (same as backend in embedded mode)
+}
```
-### Configure for Reverse Proxy
+**Important Notes:**
+- User-editable via Settings UI
+- Can be safely backed up without exposing secrets
+- Missing file results in defaults being used
+- Changes take effect immediately (no restart required)
-**Docker:**
-```bash
-docker run -d --name pulse \
- -e ALLOWED_ORIGINS="https://pulse.example.com" \
- -p 7655:7655 \
- -v pulse_data:/data \
- rcourtman/pulse:latest
+---
+
+## 📁 `nodes.enc` - Encrypted Node Credentials
+
+**Purpose:** Stores encrypted credentials for Proxmox VE and PBS nodes.
+
+**Format:** Encrypted JSON (AES-256-GCM)
+
+**Structure (when decrypted):**
+```json
+{
+ "pveInstances": [
+ {
+ "name": "pve-node1",
+ "url": "https://192.168.1.10:8006",
+ "username": "root@pam",
+ "password": "encrypted_password_here",
+ "token": "optional_api_token"
+ }
+ ],
+ "pbsInstances": [
+ {
+ "name": "backup-server",
+ "url": "https://192.168.1.20:8007",
+ "username": "admin@pbs",
+ "password": "encrypted_password_here"
+ }
+ ]
+}
```
-**LXC/Systemd:**
-```bash
-echo "ALLOWED_ORIGINS=https://pulse.example.com" >> /etc/pulse/.env
-sudo systemctl restart pulse # or pulse-backend
-```
+**Important Notes:**
+- Encrypted at rest using system-generated key
+- Credentials never exposed in UI (only "•••••" shown)
+- Export/import requires authentication
+- Automatic re-encryption on each save
-### Enable Debug Logging
-```bash
-echo "LOG_LEVEL=debug" >> /etc/pulse/.env
-sudo systemctl restart pulse # or pulse-backend
-tail -f /etc/pulse/pulse.log
-```
+---
-### Configure Discovery Subnet (Docker)
-By default, Docker containers may only discover nodes on the Docker bridge network. To scan your actual network:
-```bash
-docker run -d \
- -e DISCOVERY_SUBNET=192.168.1.0/24 \
- -p 7655:7655 \
- rcourtman/pulse:latest
-```
-Replace `192.168.1.0/24` with your actual network subnet.
+## Environment Variable Priority
-## Security Notes
+For backwards compatibility, some settings can be overridden via environment variables:
-⚠️ **Never put sensitive data in .env files!**
-- .env files are not encrypted
-- Use systemd environment variables for API_TOKEN
-- Node credentials are always stored encrypted
+1. **Authentication variables (from .env)** - Always highest priority
+ - `PULSE_AUTH_USER`, `PULSE_AUTH_PASS`, `API_TOKEN`
-## Node Setup Details
+2. **System settings (from system.json)** - Normal priority
+ - If system.json exists, it takes precedence
+ - If missing, environment variables are checked
-### Auto-Registration Script
-The setup script generated for each discovered node:
-1. Creates monitoring user (`pulse-monitor@pam` or `pulse-monitor@pbs`)
-2. Sets minimal permissions (PVEAuditor or Datastore.Audit)
-3. Generates API token with timestamp
-4. Registers with Pulse automatically
-5. Optionally cleans up old tokens
+3. **Legacy environment variables** - Lowest priority (deprecated)
+ - `POLLING_INTERVAL` - Only used if system.json doesn't exist
+ - `CONNECTION_TIMEOUT` - Can override system.json value
+ - `ALLOWED_ORIGINS` - Can override system.json value
-Example:
-```bash
-curl -sSL "http://pulse:7655/api/setup-script?type=pve&host=https%3A%2F%2F192.168.1.10%3A8006" | bash
-```
+---
-### Manual Setup
+## Security Best Practices
-If auto-registration isn't suitable, you can still set up manually:
+1. **File Permissions**
+ ```bash
+ chmod 600 /etc/pulse/.env # Only readable by owner
+ chmod 644 /etc/pulse/system.json # Readable by all, writable by owner
+ chmod 600 /etc/pulse/nodes.enc # Only readable by owner
+ ```
-**Proxmox VE:**
-```bash
-pveum user add pulse-monitor@pam
-pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
-pveum user token add pulse-monitor@pam pulse-token --privsep 0
-```
+2. **Backup Strategy**
+ - `.env` - Backup separately and securely (contains auth)
+ - `system.json` - Safe to include in regular backups
+ - `nodes.enc` - Backup with .env (contains encrypted credentials)
-**PBS:**
-```bash
-proxmox-backup-manager user create pulse-monitor@pbs
-proxmox-backup-manager acl update / Admin --auth-id pulse-monitor@pbs
-proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
-```
-
-## Updates
-
-Pulse automatically detects your deployment type and shows appropriate update instructions when a new version is available:
-
-### ProxmoxVE LXC Containers
-- Type `update` in the LXC console
-- The community script handles everything automatically
-- No manual intervention required
-
-### Docker
-- Pull the latest image: `docker pull rcourtman/pulse:latest`
-- Recreate the container with your existing settings
-- Data persists in the volume
-
-### Manual/Systemd Installations
-- Re-run the installation script: `curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash`
-- The script detects existing installations and updates them
-- Configuration is preserved
-
-### Why No In-App Updates?
-Pulse cannot update itself from the UI due to security constraints:
-- **ProxmoxVE**: The pulse user has no sudo access (security best practice)
-- **Docker**: Containers cannot restart themselves
-- **Systemd**: Service cannot restart itself without privileges
-
-This design ensures better security by requiring administrative access for updates.
-
-## Reverse Proxy Configuration
-
-Pulse requires WebSocket support for real-time updates. If using a reverse proxy (nginx, Apache, Caddy, etc.), you **MUST** enable WebSocket proxying.
-
-See the [Reverse Proxy Guide](REVERSE_PROXY.md) for detailed configurations.
-
-## Troubleshooting
-
-### Port Already in Use
-Check what's using the port:
-```bash
-sudo lsof -i :7655
-```
-
-### Permission Denied
-Ensure Pulse has write access:
-```bash
-sudo chown -R pulse:pulse /etc/pulse
-```
-
-### Changes Not Taking Effect
-Always restart after configuration changes:
-```bash
-sudo systemctl restart pulse # or pulse-backend
-```
\ No newline at end of file
+3. **Version Control**
+ - **NEVER** commit `.env` or `nodes.enc`
+ - `system.json` can be committed if it doesn't contain sensitive data
+ - Use `.gitignore` to exclude sensitive files
diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts
index 0ee8293fa..fb3f4fef3 100644
--- a/frontend-modern/src/api/settings.ts
+++ b/frontend-modern/src/api/settings.ts
@@ -2,21 +2,9 @@ import type {
SettingsResponse,
SettingsUpdateRequest
} from '@/types/settings';
+import type { SystemConfig } from '@/types/config';
import { apiFetchJSON } from '@/utils/apiClient';
-// System settings type matching Go backend
-export interface SystemSettingsUpdate {
- pollingInterval: number; // in seconds
- backendPort?: number;
- frontendPort?: number;
- allowedOrigins?: string;
- connectionTimeout?: number; // in seconds
- updateChannel?: string;
- autoUpdateEnabled?: boolean;
- autoUpdateCheckInterval?: number; // in hours
- autoUpdateTime?: string; // HH:MM format
-}
-
// Response types
export interface ApiResponse {
success?: boolean;
@@ -40,13 +28,18 @@ export class SettingsAPI {
}) as Promise;
}
- // System settings update (preferred)
- static async updateSystemSettings(settings: SystemSettingsUpdate): Promise {
+ // System settings update (preferred) - uses SystemConfig type from config.ts
+ static async updateSystemSettings(settings: Partial): Promise {
return apiFetchJSON(`${this.baseUrl}/config/system`, {
method: 'PUT',
body: JSON.stringify(settings),
}) as Promise;
}
+
+ // Get system settings - returns SystemConfig
+ static async getSystemSettings(): Promise {
+ return apiFetchJSON(`${this.baseUrl}/config/system`) as Promise;
+ }
static async validateSettings(settings: SettingsUpdateRequest): Promise {
return apiFetchJSON(`${this.baseUrl}/settings/validate`, {
diff --git a/frontend-modern/src/components/Dashboard/IOMetric.tsx b/frontend-modern/src/components/Dashboard/IOMetric.tsx
index 7feae5001..cf9511133 100644
--- a/frontend-modern/src/components/Dashboard/IOMetric.tsx
+++ b/frontend-modern/src/components/Dashboard/IOMetric.tsx
@@ -1,19 +1,36 @@
-import { createMemo, Show } from 'solid-js';
+import { createMemo, Show, createEffect, createSignal } from 'solid-js';
import { formatSpeed } from '@/utils/format';
+import { AnimatedMetric } from '@/components/shared/AnimatedMetric';
interface IOMetricProps {
- value: number;
+ value: (() => number) | number;
disabled?: boolean;
}
export function IOMetric(props: IOMetricProps) {
- const formatted = createMemo(() => formatSpeed(props.value, 0));
+ // Handle both accessor functions and direct values
+ const getValue = () => {
+ return typeof props.value === 'function' ? props.value() : props.value;
+ };
+ // Create a local signal that tracks the value
+ const [currentValue, setCurrentValue] = createSignal(getValue() || 0);
+
+ // Update the signal when value changes
+ createEffect(() => {
+ const newValue = getValue() || 0;
+ const oldValue = currentValue();
+ if (newValue !== oldValue) {
+ console.log('[IOMetric] Value change detected:', oldValue, '->', newValue, formatSpeed(newValue, 0));
+ setCurrentValue(newValue);
+ }
+ });
+
// Color based on speed (MB/s) - matching current dashboard
const colorClass = createMemo(() => {
if (props.disabled) return 'text-gray-400 dark:text-gray-500';
- const mbps = props.value / (1024 * 1024);
+ const mbps = currentValue() / (1024 * 1024);
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
if (mbps < 50) return 'text-yellow-600 dark:text-yellow-400';
@@ -22,9 +39,12 @@ export function IOMetric(props: IOMetricProps) {
return (
-}>
-
- {formatted()}
-
+
+
formatSpeed(v, 0)}
+ />
+
);
}
\ No newline at end of file
diff --git a/frontend-modern/src/components/Dashboard/NodeCard.tsx b/frontend-modern/src/components/Dashboard/NodeCard.tsx
index 670c6ba22..ea07562d0 100644
--- a/frontend-modern/src/components/Dashboard/NodeCard.tsx
+++ b/frontend-modern/src/components/Dashboard/NodeCard.tsx
@@ -1,4 +1,4 @@
-import { Component, Show, createMemo } from 'solid-js';
+import { Component, Show, createMemo, createEffect } from 'solid-js';
import type { Node } from '@/types/api';
import { formatUptime, formatBytes } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
@@ -21,16 +21,28 @@ const NodeCard: Component = (props) => {
}
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0 && props.node.connectionHealth !== 'error';
- const cpuPercent = () => Math.round(props.node.cpu * 100);
- const memPercent = () => {
+
+ // Memoize CPU percent to avoid multiple calculations
+ const cpuPercent = createMemo(() => {
+ const percent = Math.round(props.node.cpu * 100);
+ return percent;
+ });
+
+ // Track CPU updates (logging removed for cleaner output)
+ createEffect(() => {
+ cpuPercent(); // Just track the value changes
+ });
+
+ const memPercent = createMemo(() => {
if (!props.node.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return Math.round(props.node.memory.usage || 0);
- };
- const diskPercent = () => {
+ });
+
+ const diskPercent = createMemo(() => {
if (!props.node.disk || props.node.disk.total === 0) return 0;
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
- };
+ });
// Calculate normalized load (load average / cpu count)
const normalizedLoad = () => {
diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx
new file mode 100644
index 000000000..aee3d09fb
--- /dev/null
+++ b/frontend-modern/src/components/FirstRunSetup.tsx
@@ -0,0 +1,486 @@
+import { Component, createSignal, Show, onMount } from 'solid-js';
+import { showSuccess, showError } from '@/utils/toast';
+import { copyToClipboard } from '@/utils/clipboard';
+
+export const FirstRunSetup: Component = () => {
+ const [username, setUsername] = createSignal('admin');
+ const [password, setPassword] = createSignal('');
+ const [confirmPassword, setConfirmPassword] = createSignal('');
+ const [useCustomPassword, setUseCustomPassword] = createSignal(false);
+ const [generatedPassword, setGeneratedPassword] = createSignal('');
+ const [, setApiToken] = createSignal('');
+ const [isSettingUp, setIsSettingUp] = createSignal(false);
+ const [showCredentials, setShowCredentials] = createSignal(false);
+ const [savedUsername, setSavedUsername] = createSignal('');
+ const [savedPassword, setSavedPassword] = createSignal('');
+ const [savedToken, setSavedToken] = createSignal('');
+ const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
+
+ // Additional setup options
+ const [enableNotifications, setEnableNotifications] = createSignal(true);
+ const [darkMode, setDarkMode] = createSignal(
+ window.matchMedia('(prefers-color-scheme: dark)').matches
+ );
+
+ onMount(() => {
+ // Apply dark mode immediately if selected
+ if (darkMode()) {
+ document.documentElement.classList.add('dark');
+ }
+ });
+
+ const generatePassword = () => {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
+ let pass = '';
+ for (let i = 0; i < 16; i++) {
+ pass += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return pass;
+ };
+
+ const generateToken = (): string => {
+ // Generate 24 bytes (48 hex chars) to avoid hash detection issue
+ const array = new Uint8Array(24);
+ crypto.getRandomValues(array);
+ return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
+ };
+
+ const handleSetup = async () => {
+ // Validate custom password if used
+ if (useCustomPassword()) {
+ if (!password()) {
+ showError('Please enter a password');
+ return;
+ }
+ if (password() !== confirmPassword()) {
+ showError('Passwords do not match');
+ return;
+ }
+ if (password().length < 8) {
+ showError('Password must be at least 8 characters');
+ return;
+ }
+ }
+
+ setIsSettingUp(true);
+
+ // Generate password if not custom
+ const finalPassword = useCustomPassword() ? password() : generatePassword();
+ if (!useCustomPassword()) {
+ setGeneratedPassword(finalPassword);
+ }
+
+ // Generate API token
+ const token = generateToken();
+ setApiToken(token);
+
+ try {
+ const response = await fetch('/api/security/quick-setup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: username(),
+ password: finalPassword,
+ apiToken: token,
+ enableNotifications: enableNotifications(),
+ darkMode: darkMode()
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(error || 'Failed to setup security');
+ }
+
+ const result = await response.json();
+
+ if (result.skipped) {
+ // Shouldn't happen in first-run, but handle it
+ window.location.reload();
+ return;
+ }
+
+ // Save credentials for display
+ setSavedUsername(username());
+ setSavedPassword(useCustomPassword() ? password() : generatedPassword());
+ setSavedToken(token);
+
+ // Apply settings
+ localStorage.setItem('dark-mode', String(darkMode()));
+ localStorage.setItem('notifications-enabled', String(enableNotifications()));
+
+ // Show credentials
+ setShowCredentials(true);
+ showSuccess('Security configured successfully!');
+
+ } catch (error) {
+ showError(`Failed to setup security: ${error}`);
+ } finally {
+ setIsSettingUp(false);
+ }
+ };
+
+ const handleCopy = async (type: 'password' | 'token') => {
+ const value = type === 'password' ? savedPassword() : savedToken();
+ const success = await copyToClipboard(value);
+ if (success) {
+ setCopied(type);
+ setTimeout(() => setCopied(null), 2000);
+ }
+ };
+
+ const downloadCredentials = () => {
+ const credentials = `Pulse Security Credentials
+========================
+Generated: ${new Date().toISOString()}
+
+Web Interface Login:
+-------------------
+URL: ${window.location.origin}
+Username: ${savedUsername()}
+Password: ${savedPassword()}
+
+API Access:
+-----------
+API Token: ${savedToken()}
+
+Example API Usage:
+curl -H "X-API-Token: ${savedToken()}" ${window.location.origin}/api/state
+
+IMPORTANT: Keep these credentials secure!
+`;
+
+ const blob = new Blob([credentials], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `pulse-credentials-${Date.now()}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+ {/* Logo/Header */}
+
+
+
+ Pulse Logo
+
+
+
+
+ Pulse
+
+
+ Let's set up your monitoring dashboard
+
+
+
+
+
+
+
+ Initial Security Setup
+
+
+
+ {/* Username */}
+
+
+ Admin Username
+
+ setUsername(e.currentTarget.value)}
+ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="admin"
+ />
+
+
+ {/* Password Setup */}
+
+
+ Admin Password
+
+
+
+ setUseCustomPassword(false)}
+ class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
+ !useCustomPassword()
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
+ }`}
+ >
+ Generate Secure Password
+
+ setUseCustomPassword(true)}
+ class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
+ useCustomPassword()
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
+ }`}
+ >
+ Set Custom Password
+
+
+
+
+
+ setPassword(e.currentTarget.value)}
+ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="Enter password (min 8 characters)"
+ />
+ setConfirmPassword(e.currentTarget.value)}
+ class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="Confirm password"
+ />
+
+
+
+
+
+
+ A secure 16-character password will be generated for you.
+ Make sure to save it when shown!
+
+
+
+
+
+ {/* Additional Settings */}
+
+
+ Initial Configuration
+
+
+ {/* Enable Notifications */}
+
+
+
+ Enable Desktop Notifications
+
+
+ Get notified about alerts and important events
+
+
+
setEnableNotifications(!enableNotifications())}
+ class={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
+ enableNotifications() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
+ }`}
+ >
+
+
+
+
+ {/* Dark Mode */}
+
+
+
+ Dark Mode
+
+
+ Use dark theme for the dashboard
+
+
+
{
+ setDarkMode(!darkMode());
+ if (!darkMode()) {
+ document.documentElement.classList.remove('dark');
+ } else {
+ document.documentElement.classList.add('dark');
+ }
+ }}
+ class={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
+ darkMode() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
+ }`}
+ >
+
+
+
+
+
+ {/* Info Box */}
+
+
+ What happens next:
+
+
+
+ ✓
+ Your admin account will be created
+
+
+ ✓
+ An API token will be generated for automation
+
+
+ ✓
+ All API endpoints will be protected
+
+
+ ✓
+ You'll need to login to access the dashboard
+
+
+
+
+ {/* Setup Button */}
+
+ {isSettingUp() ? 'Setting up...' : 'Complete Setup'}
+
+
+
+
+
+
+
+
+
+
+ Setup Complete!
+
+
+ Save your credentials now - they won't be shown again
+
+
+
+
+ {/* Username */}
+
+
+ Username
+
+
+ {savedUsername()}
+
+
+
+ {/* Password */}
+
+
+ Password
+
+
+
+ {savedPassword()}
+
+
handleCopy('password')}
+ class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Copy password"
+ >
+ {copied() === 'password' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {/* API Token */}
+
+
+ API Token (for automation)
+
+
+
+ {savedToken()}
+
+
handleCopy('token')}
+ class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Copy token"
+ >
+ {copied() === 'token' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {/* Warning */}
+
+
+ ⚠️ Important
+
+
+ These credentials will never be shown again. Save them in a password manager now!
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ Download Credentials
+
+
window.location.reload()}
+ class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
+ >
+ Continue to Login
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend-modern/src/components/Settings/APIOnlySetup.tsx b/frontend-modern/src/components/Settings/APIOnlySetup.tsx
new file mode 100644
index 000000000..d63f7d3cc
--- /dev/null
+++ b/frontend-modern/src/components/Settings/APIOnlySetup.tsx
@@ -0,0 +1,125 @@
+import { Component, createSignal, Show } from 'solid-js';
+import { showSuccess, showError } from '@/utils/toast';
+import { copyToClipboard } from '@/utils/clipboard';
+
+interface APIOnlySetupProps {
+ onTokenGenerated?: () => void;
+}
+
+export const APIOnlySetup: Component = (props) => {
+ const [isGenerating, setIsGenerating] = createSignal(false);
+ const [token, setToken] = createSignal(null);
+ const [showToken, setShowToken] = createSignal(false);
+ const [copied, setCopied] = createSignal(false);
+
+ const generateToken = async () => {
+ setIsGenerating(true);
+
+ try {
+ const response = await fetch('/api/security/regenerate-token', {
+ method: 'POST',
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(error || 'Failed to generate token');
+ }
+
+ const data = await response.json();
+ setToken(data.token);
+ setShowToken(true);
+ showSuccess('API token generated! Save it now - it won\'t be shown again.');
+ } catch (error) {
+ showError(`Failed to generate token: ${error}`);
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!token()) return;
+
+ const success = await copyToClipboard(token()!);
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else {
+ showError('Failed to copy to clipboard');
+ }
+ };
+
+ return (
+
+
+
+
+ Generate an API token for programmatic access. Use it for:
+
+
+ Automation scripts and CI/CD pipelines
+ Monitoring integrations
+ Third-party applications
+
+
+
+
+ Note: Without password authentication enabled,
+ the UI will remain publicly accessible.
+
+
+
+
+ {isGenerating() ? 'Generating...' : 'Generate API Token'}
+
+
+
+
+
+
+
+
+ ✅ API Token Generated!
+
+
+ Save this token now - it will never be shown again!
+
+
+
+
+
Your API Token
+
+
+ {token()}
+
+
+ {copied() ? 'Copied!' : 'Copy'}
+
+
+
+
+
+
{
+ setShowToken(false);
+ setToken(null);
+ if (props.onTokenGenerated) {
+ props.onTokenGenerated();
+ }
+ }}
+ class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
+ >
+ Done - I've Saved My Token
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend-modern/src/components/Settings/GenerateAPIToken.tsx b/frontend-modern/src/components/Settings/GenerateAPIToken.tsx
index 08d1ff7e1..d40afa1dd 100644
--- a/frontend-modern/src/components/Settings/GenerateAPIToken.tsx
+++ b/frontend-modern/src/components/Settings/GenerateAPIToken.tsx
@@ -1,25 +1,34 @@
-import { Component, createSignal, Show } from 'solid-js';
+import { Component, createSignal, Show, createEffect } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
+import { apiFetch } from '@/utils/apiClient';
-export const GenerateAPIToken: Component = () => {
+interface GenerateAPITokenProps {
+ currentTokenHint?: string;
+}
+
+export const GenerateAPIToken: Component = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [newToken, setNewToken] = createSignal(null);
const [showToken, setShowToken] = createSignal(false);
const [copied, setCopied] = createSignal(false);
- const [deploymentType, setDeploymentType] = createSignal('');
+ const [currentHint, setCurrentHint] = createSignal(props.currentTokenHint || '');
+ const [showConfirm, setShowConfirm] = createSignal(false);
+
+ // Update hint when props change
+ createEffect(() => {
+ if (props.currentTokenHint) {
+ setCurrentHint(props.currentTokenHint);
+ }
+ });
const generateNewToken = async () => {
- if (!confirm('Generate a new API token? The old token will stop working immediately.')) {
- return;
- }
-
setIsGenerating(true);
+ setShowConfirm(false);
try {
- const response = await fetch('/api/security/regenerate-token', {
- method: 'POST',
- credentials: 'include'
+ const response = await apiFetch('/api/security/regenerate-token', {
+ method: 'POST'
});
if (!response.ok) {
@@ -29,7 +38,10 @@ export const GenerateAPIToken: Component = () => {
const data = await response.json();
setNewToken(data.token);
- setDeploymentType(data.deploymentType);
+ // Update the current hint with the new token
+ if (data.token && data.token.length >= 20) {
+ setCurrentHint(data.token.slice(0, 8) + '...' + data.token.slice(-4));
+ }
setShowToken(true);
showSuccess('New API token generated! Save it now - it won\'t be shown again.');
} catch (error) {
@@ -51,19 +63,6 @@ export const GenerateAPIToken: Component = () => {
}
};
- const getRestartInstructions = () => {
- switch(deploymentType()) {
- case 'docker':
- return 'Restart your Docker container to activate the new token.';
- case 'proxmoxve':
- return 'Restart Pulse from the ProxmoxVE host to activate the new token.';
- case 'systemd':
- return 'Run: sudo systemctl restart pulse';
- default:
- return 'Restart the Pulse service to activate the new token.';
- }
- };
-
return (
@@ -71,25 +70,25 @@ export const GenerateAPIToken: Component = () => {
API Token Active
+ 0}>
+
+
+ Current token: {currentHint()}
+
+
+
An API token is configured for this instance. Use it with the X-API-Token header for automation.
setShowConfirm(true)}
disabled={isGenerating()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating() ? 'Generating...' : 'Generate New Token'}
-
-
-
Using the API Token:
-
- curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/...
-
-
@@ -118,15 +117,15 @@ export const GenerateAPIToken: Component = () => {
-
+
-
-
+
+
-
-
Restart Required
-
{getRestartInstructions()}
-
The old token has been invalidated and will no longer work.
+
+
Token Active Immediately!
+
Your new API token is active and ready to use.
+
The old token (if any) has been invalidated.
@@ -142,6 +141,34 @@ export const GenerateAPIToken: Component = () => {
+
+
+
+
+
+ Generate New API Token?
+
+
+ This will generate a new API token and immediately invalidate the current token .
+ Any scripts or integrations using the old token will stop working.
+
+
+ setShowConfirm(false)}
+ class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+
+
+ Generate New Token
+
+
+
+
+
);
};
\ No newline at end of file
diff --git a/frontend-modern/src/components/Settings/NodeModal.tsx b/frontend-modern/src/components/Settings/NodeModal.tsx
index 4fed7af1c..2df5b9e44 100644
--- a/frontend-modern/src/components/Settings/NodeModal.tsx
+++ b/frontend-modern/src/components/Settings/NodeModal.tsx
@@ -1,6 +1,7 @@
import { Component, Show, createSignal, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { NodeConfig } from '@/types/nodes';
+import type { SecurityStatus } from '@/types/config';
import { copyToClipboard } from '@/utils/clipboard';
import { showSuccess, showError } from '@/utils/toast';
import { NodesAPI } from '@/api/nodes';
@@ -14,7 +15,7 @@ interface NodeModalProps {
onSave: (nodeData: Partial) => void;
showBackToDiscovery?: boolean;
onBackToDiscovery?: () => void;
- securityStatus?: any;
+ securityStatus?: Partial;
}
export const NodeModal: Component = (props) => {
@@ -485,7 +486,6 @@ export const NodeModal: Component = (props) => {
}
// Always regenerate URL when host changes
- console.log('Generating setup URL with host:', formData().host);
const response = await fetch('/api/setup-script-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
diff --git a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx
index c86980ec2..7a6cd4d82 100644
--- a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx
+++ b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx
@@ -35,7 +35,8 @@ export const QuickSecuritySetup: Component = (props) =>
};
const generateToken = (): string => {
- const array = new Uint8Array(32);
+ // Generate 24 bytes (48 hex chars) to avoid hash detection issue with 64-char tokens
+ const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
};
@@ -89,17 +90,29 @@ export const QuickSecuritySetup: Component = (props) =>
throw new Error(error || 'Failed to setup security');
}
- // Response is successful, no need to parse result
+ // Parse response to check if setup was skipped
+ const result = await response.json();
+
+ if (result.skipped) {
+ // Security was already configured, don't show credentials
+ showError('Security is already configured. Please remove existing security first if you want to reconfigure.');
+ if (props.onConfigured) {
+ props.onConfigured();
+ }
+ return;
+ }
+
+ // Response is successful and security was newly configured
setCredentials(newCredentials);
setShowCredentials(true);
// Show success message
- showSuccess('Security configured! Settings will apply after restart.');
+ showSuccess('Security configured! Save your credentials before continuing.');
- // Notify parent component to refresh security status
- if (props.onConfigured) {
- props.onConfigured();
- }
+ // DON'T notify parent yet - wait until user dismisses credentials
+ // if (props.onConfigured) {
+ // props.onConfigured();
+ // }
} catch (error) {
showError(`Failed to setup security: ${error}`);
} finally {
@@ -366,6 +379,23 @@ Important:
Save your credentials above - they won't be shown again.
+
+
+ {
+ setShowCredentials(false);
+ // Now notify parent that configuration is complete
+ if (props.onConfigured) {
+ props.onConfigured();
+ }
+ // Reload the page to trigger login screen
+ window.location.reload();
+ }}
+ class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
+ >
+ Done - I've Saved My Credentials
+
+
diff --git a/frontend-modern/src/components/Settings/RemovePasswordModal.tsx b/frontend-modern/src/components/Settings/RemovePasswordModal.tsx
deleted file mode 100644
index 27faaa76e..000000000
--- a/frontend-modern/src/components/Settings/RemovePasswordModal.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import { Component, createSignal, Show } from 'solid-js';
-import { Portal } from 'solid-js/web';
-import { showSuccess } from '@/utils/toast';
-
-interface RemovePasswordModalProps {
- isOpen: boolean;
- onClose: () => void;
-}
-
-export const RemovePasswordModal: Component = (props) => {
- const [currentPassword, setCurrentPassword] = createSignal('');
- const [loading, setLoading] = createSignal(false);
- const [error, setError] = createSignal('');
-
- const handleSubmit = async (e: Event) => {
- e.preventDefault();
- setError('');
-
- if (!currentPassword()) {
- setError('Current password is required');
- return;
- }
-
- setLoading(true);
-
- try {
- // Get CSRF token from cookie
- const csrfToken = document.cookie
- .split('; ')
- .find(row => row.startsWith('pulse_csrf='))
- ?.split('=')[1];
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- 'Authorization': `Basic ${btoa(`admin:${currentPassword()}`)}`,
- };
-
- // Add CSRF token if available
- if (csrfToken) {
- headers['X-CSRF-Token'] = csrfToken;
- }
-
- const response = await fetch('/api/security/remove-password', {
- method: 'POST',
- headers,
- body: JSON.stringify({
- currentPassword: currentPassword(),
- }),
- credentials: 'include', // Important for cookies
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.message || 'Failed to remove password');
- }
-
- // Show success message
- showSuccess(data.message || 'Password authentication removed. Pulse is now running without authentication.');
-
- // Clear form
- setCurrentPassword('');
- props.onClose();
-
- // Reload the page to reflect the changes (password is removed from current session)
- setTimeout(() => {
- window.location.reload();
- }, 3000);
- } catch (err: any) {
- setError(err.message || 'Failed to remove password');
- } finally {
- setLoading(false);
- }
- };
-
- const handleClose = () => {
- setCurrentPassword('');
- setError('');
- props.onClose();
- };
-
- return (
-
-
-
-
-
- Remove Password Authentication
-
-
-
-
- Warning: This will disable password authentication.
- Pulse will be accessible without any login. Only do this if you're on a trusted network.
-
-
-
-
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx
index cd061dc73..1848abda2 100644
--- a/frontend-modern/src/components/Settings/Settings.tsx
+++ b/frontend-modern/src/components/Settings/Settings.tsx
@@ -2,10 +2,8 @@ import { Component, createSignal, onMount, For, Show, createEffect, onCleanup }
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { NodeModal } from './NodeModal';
-import { QuickSecuritySetup } from './QuickSecuritySetup';
import { GenerateAPIToken } from './GenerateAPIToken';
import { ChangePasswordModal } from './ChangePasswordModal';
-import { RemovePasswordModal } from './RemovePasswordModal';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
@@ -95,12 +93,11 @@ const Settings: Component = () => {
const [currentNodeType, setCurrentNodeType] = createSignal<'pve' | 'pbs'>('pve');
const [modalResetKey, setModalResetKey] = createSignal(0);
const [showPasswordModal, setShowPasswordModal] = createSignal(false);
- const [showRemovePasswordModal, setShowRemovePasswordModal] = createSignal(false);
// System settings
- const [pollingInterval, setPollingInterval] = createSignal(5);
+ // PBS polling interval removed - fixed at 10 seconds
const [allowedOrigins, setAllowedOrigins] = createSignal('*');
- const [connectionTimeout, setConnectionTimeout] = createSignal(10);
+ // Connection timeout removed - backend-only setting
// Update settings
const [versionInfo, setVersionInfo] = createSignal(null);
@@ -117,7 +114,8 @@ const Settings: Component = () => {
// Security
const [securityStatus, setSecurityStatus] = createSignal<{
- apiTokenConfigured: boolean;
+ apiTokenConfigured: boolean;
+ apiTokenHint?: string;
requiresAuth: boolean;
exportProtected: boolean;
unprotectedExportAllowed: boolean;
@@ -242,7 +240,6 @@ const Settings: Component = () => {
onMount(async () => {
// Subscribe to events
const unsubscribeAutoRegister = eventBus.on('node_auto_registered', () => {
- console.log('[Settings] Node auto-registered, closing modal and refreshing nodes');
// Close any open modals
setShowNodeModal(false);
setEditingNode(null);
@@ -252,12 +249,10 @@ const Settings: Component = () => {
});
const unsubscribeRefresh = eventBus.on('refresh_nodes', () => {
- console.log('[Settings] Refreshing nodes');
loadNodes();
});
const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => {
- console.log('[Settings] Discovery updated:', data);
// If this is an immediate update (from node deletion), merge with existing
if (data && data.immediate && data.servers) {
setDiscoveredNodes(prev => {
@@ -290,7 +285,6 @@ const Settings: Component = () => {
if (showNodeModal()) {
// Start polling every 3 seconds when modal is open
pollInterval = setInterval(() => {
- console.log('[Settings] Polling for node updates...');
loadNodes();
loadDiscoveredNodes();
}, 3000);
@@ -336,9 +330,9 @@ const Settings: Component = () => {
const systemResponse = await fetch('/api/config/system');
if (systemResponse.ok) {
const systemSettings = await systemResponse.json();
- setPollingInterval(systemSettings.pollingInterval || 5);
+ // PBS polling interval is now fixed at 10 seconds
setAllowedOrigins(systemSettings.allowedOrigins || '*');
- setConnectionTimeout(systemSettings.connectionTimeout || 10);
+ // Connection timeout is backend-only
// Load auto-update settings
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
@@ -348,9 +342,7 @@ const Settings: Component = () => {
}
} else {
// Fallback to old endpoint
- const response = await SettingsAPI.getSettings();
- const settings = response.current;
- setPollingInterval((settings.monitoring.pollingInterval || 5000) / 1000);
+ await SettingsAPI.getSettings();
}
} catch (error) {
console.error('Failed to load settings:', error);
@@ -376,9 +368,9 @@ const Settings: Component = () => {
if (activeTab() === 'system') {
// Save system settings using typed API
await SettingsAPI.updateSystemSettings({
- pollingInterval: pollingInterval(),
+ // PBS polling interval is now fixed at 10 seconds
allowedOrigins: allowedOrigins(),
- connectionTimeout: connectionTimeout(),
+ // Connection timeout is backend-only
updateChannel: updateChannel(),
autoUpdateEnabled: autoUpdateEnabled(),
autoUpdateCheckInterval: autoUpdateCheckInterval(),
@@ -1137,62 +1129,6 @@ const Settings: Component = () => {
- {/* Performance Settings */}
-
-
-
-
-
- Performance Settings
-
-
-
-
-
-
Polling Interval
-
- How often to fetch data from servers
-
-
-
{
- setPollingInterval(parseInt(e.currentTarget.value));
- setHasUnsavedChanges(true);
- }}
- class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
- >
- 3 seconds
- 5 seconds
- 10 seconds
- 30 seconds
- 1 minute
-
-
-
-
-
-
Connection Timeout
-
- Max wait time for node responses
-
-
-
{
- setConnectionTimeout(parseInt(e.currentTarget.value));
- setHasUnsavedChanges(true);
- }}
- class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
- >
- 5 seconds
- 10 seconds
- 20 seconds
- 30 seconds
-
-
-
-
{/* Network Settings */}
@@ -1437,29 +1373,21 @@ const Settings: Component = () => {
{/* Security Tab */}
- {/* Authentication Status */}
+ {/* Authentication */}
{/* Header */}
-
-
-
-
-
-
Authentication
-
Password protection enabled
-
-
-
-
-
+
+
+
+
+
Authentication
+
Manage your login credentials
+
@@ -1481,20 +1409,6 @@ const Settings: Component = () => {
-
setShowRemovePasswordModal(true)}
- class="flex items-center gap-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all group"
- >
-
-
-
Remove Password
-
Disable authentication
-
-
@@ -1519,26 +1433,70 @@ const Settings: Component = () => {
After restarting, you'll need to log in with your saved credentials.
+
+
+
+ How to restart Pulse:
+
+
+
+
+
+ Type update in your ProxmoxVE console
+
+
+ Or restart manually with: systemctl restart pulse
+
+
+
+
+
+
+
Restart your Docker container:
+
+ docker restart pulse
+
+
+
+
+
+
+
Restart the service:
+
+ sudo systemctl restart pulse
+
+
+
+
+
+
+
Restart the development server:
+
+ sudo systemctl restart pulse-backend
+
+
+
+
+
+
+
Restart Pulse using your deployment method
+
+
+
+
+
+
+ 💡 Tip: Make sure you've saved your credentials before restarting!
+
+
- {/* Show setup when no auth and not pending */}
-
- {
- // Refresh security status after configuration
- try {
- const response = await fetch('/api/security/status');
- if (response.ok) {
- const status = await response.json();
- setSecurityStatus(status);
- }
- } catch (err) {
- console.error('Failed to refresh security status:', err);
- }
- }} />
-
+ {/* Security setup now handled by first-run wizard */}
+
+ {/* Removed confusing API Token section when no auth exists - API is already open */}
{/* API Token - Show current token when auth is enabled */}
@@ -1560,7 +1518,7 @@ const Settings: Component = () => {
{/* Content */}
-
+
@@ -1835,7 +1793,6 @@ const Settings: Component = () => {
storageCount: state.storage?.length || 0
},
settings: {
- pollingInterval: pollingInterval()
}
};
@@ -1874,7 +1831,7 @@ const Settings: Component = () => {
}}
nodeType="pve"
editingNode={editingNode()?.type === 'pve' ? editingNode() ?? undefined : undefined}
- securityStatus={securityStatus()}
+ securityStatus={securityStatus() ?? undefined}
onSave={async (nodeData) => {
try {
if (editingNode() && editingNode()!.id) {
@@ -1933,7 +1890,7 @@ const Settings: Component = () => {
}}
nodeType="pbs"
editingNode={editingNode()?.type === 'pbs' ? editingNode() ?? undefined : undefined}
- securityStatus={securityStatus()}
+ securityStatus={securityStatus() ?? undefined}
onSave={async (nodeData) => {
try {
if (editingNode() && editingNode()!.id) {
@@ -2244,11 +2201,6 @@ const Settings: Component = () => {
isOpen={showPasswordModal()}
onClose={() => setShowPasswordModal(false)}
/>
-
- setShowRemovePasswordModal(false)}
- />
>
);
};
diff --git a/frontend-modern/src/components/shared/AnimatedMetric.tsx b/frontend-modern/src/components/shared/AnimatedMetric.tsx
new file mode 100644
index 000000000..8ae4c85e0
--- /dev/null
+++ b/frontend-modern/src/components/shared/AnimatedMetric.tsx
@@ -0,0 +1,80 @@
+import { Component, createEffect, createSignal, onCleanup } from 'solid-js';
+import { formatBytes } from '@/utils/format';
+
+interface AnimatedMetricProps {
+ value: number;
+ formatter?: (value: number) => string;
+ className?: string;
+}
+
+export const AnimatedMetric: Component = (props) => {
+ const [displayValue, setDisplayValue] = createSignal(props.value);
+ const [oldValue, setOldValue] = createSignal(props.value);
+ const [showGhost, setShowGhost] = createSignal(false);
+ const [animClass, setAnimClass] = createSignal('');
+ let timeoutId: number;
+ let hasInitialized = false;
+
+ createEffect(() => {
+ const newVal = props.value;
+ const prevVal = displayValue();
+
+ // Skip first render
+ if (!hasInitialized) {
+ hasInitialized = true;
+ setDisplayValue(newVal);
+ setOldValue(newVal);
+ return;
+ }
+
+ // Only animate if value changed
+ if (newVal !== prevVal) {
+ clearTimeout(timeoutId);
+
+ // Store old value for ghost
+ setOldValue(prevVal);
+ setShowGhost(true);
+
+ // Set animation direction
+ if (newVal > prevVal) {
+ setAnimClass('up');
+ console.log('[AnimatedMetric] Going UP:', prevVal, '->', newVal);
+ } else {
+ setAnimClass('down');
+ console.log('[AnimatedMetric] Going DOWN:', prevVal, '->', newVal);
+ }
+
+ // Update to new value
+ setDisplayValue(newVal);
+
+ // Remove ghost after animation
+ timeoutId = window.setTimeout(() => {
+ setShowGhost(false);
+ setAnimClass('');
+ }, 500);
+ }
+ });
+
+ onCleanup(() => clearTimeout(timeoutId));
+
+ const format = props.formatter || ((v: number) => formatBytes(v) + '/s');
+
+ return (
+
+ {showGhost() && (
+
+ {format(oldValue())}
+
+ )}
+
+ {format(displayValue())}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend-modern/src/index.tsx b/frontend-modern/src/index.tsx
index e87d37f28..26d857699 100644
--- a/frontend-modern/src/index.tsx
+++ b/frontend-modern/src/index.tsx
@@ -1,6 +1,7 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
+import './styles/animations.css';
import App from './App';
import { logger } from './utils/logger';
diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts
index efdd32161..eeee0dbf9 100644
--- a/frontend-modern/src/stores/websocket.ts
+++ b/frontend-modern/src/stores/websocket.ts
@@ -195,7 +195,6 @@ export function createWebSocketStore(url: string) {
const nodeName = node.name || node.host;
const nodeType = node.type === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server';
- console.log('[WebSocket] Showing notification for node:', nodeName);
notificationStore.success(
`🎉 ${nodeType} node "${nodeName}" was successfully auto-registered and is now being monitored!`,
8000
@@ -209,15 +208,15 @@ export function createWebSocketStore(url: string) {
eventBus.emit('refresh_nodes');
} else if (message.type === 'node_deleted' || message.type === 'nodes_changed') {
// Nodes configuration has changed, refresh the list
- console.log('[WebSocket] Nodes configuration changed, refreshing...');
eventBus.emit('refresh_nodes');
} else if (message.type === 'discovery_update') {
// Discovery scan completed with new results
- console.log('[WebSocket] Discovery update received:', message.data);
eventBus.emit('discovery_updated', message.data);
} else {
- // Log any unhandled message types
- console.log('[WebSocket] Unhandled message type:', (message as any).type);
+ // Log any unhandled message types in dev mode only
+ if (import.meta.env.DEV) {
+ // Silently ignore unhandled message types
+ }
}
} catch (err) {
logger.error('Failed to process WebSocket message', err);
diff --git a/frontend-modern/src/styles/animations.css b/frontend-modern/src/styles/animations.css
new file mode 100644
index 000000000..4db936f06
--- /dev/null
+++ b/frontend-modern/src/styles/animations.css
@@ -0,0 +1,193 @@
+/* Animated Metric Container */
+.metric-container {
+ position: relative;
+ display: inline-block;
+ overflow: visible;
+ min-height: 1.5em;
+}
+
+.metric-value {
+ display: inline-block;
+ position: relative;
+ z-index: 2;
+}
+
+.metric-ghost {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ pointer-events: none;
+}
+
+/* Ghost sliding UP and fading out (old value when increasing) */
+@keyframes ghostSlideUp {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ }
+ 100% {
+ transform: translateY(-20px);
+ opacity: 0;
+ filter: blur(2px);
+ }
+}
+
+/* Ghost sliding DOWN and fading out (old value when decreasing) */
+@keyframes ghostSlideDown {
+ 0% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ }
+ 100% {
+ transform: translateY(20px);
+ opacity: 0;
+ filter: blur(2px);
+ }
+}
+
+/* New value entering from below (when increasing) */
+@keyframes enterFromBelow {
+ 0% {
+ transform: translateY(15px);
+ opacity: 0;
+ filter: blur(1px);
+ color: rgb(34, 197, 94); /* green-500 */
+ }
+ 50% {
+ color: rgb(34, 197, 94);
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ color: inherit;
+ }
+}
+
+/* New value entering from above (when decreasing) */
+@keyframes enterFromAbove {
+ 0% {
+ transform: translateY(-15px);
+ opacity: 0;
+ filter: blur(1px);
+ color: rgb(239, 68, 68); /* red-500 */
+ }
+ 50% {
+ color: rgb(239, 68, 68);
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ color: inherit;
+ }
+}
+
+/* Apply animations */
+.metric-ghost-up {
+ animation: ghostSlideUp 0.4s ease-out forwards;
+ color: rgb(156, 163, 175); /* gray-400 */
+}
+
+.metric-ghost-down {
+ animation: ghostSlideDown 0.4s ease-out forwards;
+ color: rgb(156, 163, 175); /* gray-400 */
+}
+
+.metric-entering-up {
+ animation: enterFromBelow 0.4s ease-out;
+}
+
+.metric-entering-down {
+ animation: enterFromAbove 0.4s ease-out;
+}
+
+/* Dark mode adjustments */
+.dark .metric-ghost-up,
+.dark .metric-ghost-down {
+ color: rgb(107, 114, 128); /* gray-500 */
+}
+
+.dark .metric-entering-up {
+ animation: enterFromBelowDark 0.4s ease-out;
+}
+
+.dark .metric-entering-down {
+ animation: enterFromAboveDark 0.4s ease-out;
+}
+
+@keyframes enterFromBelowDark {
+ 0% {
+ transform: translateY(15px);
+ opacity: 0;
+ filter: blur(1px);
+ color: rgb(74, 222, 128); /* green-400 */
+ }
+ 50% {
+ color: rgb(74, 222, 128);
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ color: inherit;
+ }
+}
+
+@keyframes enterFromAboveDark {
+ 0% {
+ transform: translateY(-15px);
+ opacity: 0;
+ filter: blur(1px);
+ color: rgb(248, 113, 113); /* red-400 */
+ }
+ 50% {
+ color: rgb(248, 113, 113);
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ filter: blur(0);
+ color: inherit;
+ }
+}
+
+/* Optional: Add a subtle glow during animation */
+.metric-entering-up::before,
+.metric-entering-down::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 150%;
+ height: 150%;
+ pointer-events: none;
+ border-radius: 50%;
+ animation: pulseGlow 0.4s ease-out;
+}
+
+.metric-entering-up::before {
+ background: radial-gradient(circle, rgba(34, 197, 94, 0.3) 0%, transparent 60%);
+}
+
+.metric-entering-down::before {
+ background: radial-gradient(circle, rgba(239, 68, 68, 0.3) 0%, transparent 60%);
+}
+
+@keyframes pulseGlow {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.5);
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(1.5);
+ }
+}
\ No newline at end of file
diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts
new file mode 100644
index 000000000..0a5c0d84c
--- /dev/null
+++ b/frontend-modern/src/types/config.ts
@@ -0,0 +1,142 @@
+/**
+ * Configuration Type Definitions
+ *
+ * This file defines the types for Pulse's configuration structure.
+ * Configuration is split into three files:
+ *
+ * 1. .env - Authentication credentials (AuthConfig)
+ * 2. system.json - Application settings (SystemConfig)
+ * 3. nodes.enc - Encrypted node credentials (NodesConfig)
+ */
+
+/**
+ * Authentication configuration from .env file
+ * These are environment variables for authentication ONLY
+ */
+export interface AuthConfig {
+ PULSE_AUTH_USER: string; // Admin username
+ PULSE_AUTH_PASS: string; // Bcrypt hashed password
+ API_TOKEN: string; // API authentication token
+ ENABLE_AUDIT_LOG?: boolean; // Enable audit logging
+}
+
+/**
+ * System settings from system.json file
+ * These are application behavior settings
+ */
+export interface SystemConfig {
+ pollingInterval: number; // Legacy - seconds between node polls
+ pvePollingInterval?: number; // Proxmox polling interval in seconds (2-60)
+ pbsPollingInterval?: number; // DEPRECATED - PBS polling fixed at 10 seconds
+ connectionTimeout?: number; // Seconds before timeout (default: 10)
+ autoUpdateEnabled: boolean; // Enable auto-updates
+ updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
+ autoUpdateCheckInterval?: number; // Hours between update checks
+ autoUpdateTime?: string; // Time for updates (HH:MM format)
+ allowedOrigins?: string; // CORS allowed origins
+ backendPort?: number; // Backend API port (default: 7655)
+ frontendPort?: number; // Frontend UI port (default: 7655)
+}
+
+/**
+ * Node instance configuration (stored encrypted in nodes.enc)
+ */
+export interface NodeInstance {
+ name: string;
+ url: string;
+ username: string;
+ password?: string; // Encrypted at rest
+ token?: string; // Optional API token
+ fingerprint?: string; // TLS certificate fingerprint
+}
+
+/**
+ * PVE-specific node configuration
+ */
+export interface PVENodeConfig extends NodeInstance {
+ realm?: string; // Authentication realm (pam, pve, etc.)
+}
+
+/**
+ * PBS-specific node configuration
+ */
+export interface PBSNodeConfig extends NodeInstance {
+ datastore?: string; // Default datastore
+}
+
+/**
+ * Nodes configuration from nodes.enc file
+ */
+export interface NodesConfig {
+ pveInstances: PVENodeConfig[];
+ pbsInstances: PBSNodeConfig[];
+}
+
+/**
+ * Complete configuration structure
+ */
+export interface PulseConfig {
+ auth: Partial; // From .env
+ system: SystemConfig; // From system.json
+ nodes: NodesConfig; // From nodes.enc
+}
+
+/**
+ * API response for security status
+ */
+export interface SecurityStatus {
+ hasAuthentication: boolean;
+ apiTokenConfigured: boolean;
+ apiTokenHint: string;
+ requiresAuth: boolean;
+ credentialsEncrypted: boolean;
+ exportProtected: boolean;
+ hasAuditLogging: boolean;
+ configuredButPendingRestart: boolean;
+}
+
+/**
+ * First-run setup request
+ */
+export interface SetupRequest {
+ username: string;
+ password: string;
+ apiToken?: string;
+ pollingInterval?: number;
+ enableNotifications?: boolean;
+ darkMode?: boolean;
+}
+
+/**
+ * Type guards for configuration validation
+ */
+export const isValidPollingInterval = (value: number): boolean => {
+ return value >= 2 && value <= 60; // 2s minimum now that sync is fixed
+};
+
+export const isValidUpdateChannel = (value: string): value is 'stable' | 'rc' | 'beta' => {
+ return ['stable', 'rc', 'beta'].includes(value);
+};
+
+export const isValidTimeFormat = (value: string): boolean => {
+ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
+};
+
+/**
+ * Default values for configuration
+ */
+export const DEFAULT_CONFIG: {
+ system: SystemConfig;
+} = {
+ system: {
+ pollingInterval: 5,
+ connectionTimeout: 10,
+ autoUpdateEnabled: false,
+ updateChannel: 'stable',
+ autoUpdateCheckInterval: 24,
+ autoUpdateTime: '03:00',
+ allowedOrigins: '',
+ backendPort: 7655,
+ frontendPort: 7655,
+ }
+};
\ No newline at end of file
diff --git a/frontend-modern/src/utils/logger.ts b/frontend-modern/src/utils/logger.ts
index a21c482ca..0aab47f9e 100644
--- a/frontend-modern/src/utils/logger.ts
+++ b/frontend-modern/src/utils/logger.ts
@@ -1,4 +1,4 @@
-// Simple logger - just console.log with timestamps
+// Simple logger with environment-aware logging
const isDev = import.meta.env.DEV;
export const logger = {
@@ -7,7 +7,10 @@ export const logger = {
},
info: (message: string, data?: unknown) => {
- console.log(`[INFO] ${message}`, data || '');
+ // Only show critical info messages in production
+ if (isDev || message.includes('established') || message.includes('error') || message.includes('failed')) {
+ console.log(`[INFO] ${message}`, data || '');
+ }
},
warn: (message: string, data?: unknown) => {
diff --git a/go.mod b/go.mod
index 3062fd286..94eecc088 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.23.0
toolchain go1.23.4
require (
+ github.com/fsnotify/fsnotify v1.9.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.34.0
diff --git a/go.sum b/go.sum
index 2aa02c247..7012bbec1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
diff --git a/hot-dev.sh b/hot-dev.sh
index d898b1e32..00547531e 100755
--- a/hot-dev.sh
+++ b/hot-dev.sh
@@ -16,9 +16,10 @@ echo "Just edit frontend files and see changes instantly!"
echo "Press Ctrl+C to stop"
echo "========================================="
-# Kill any existing Pulse processes
+# Kill any existing Pulse processes (but NOT ttyd/tmux which run Claude Code!)
sudo systemctl stop pulse-backend 2>/dev/null
-pkill -f "pulse" 2>/dev/null
+# Use exact match to only kill the "pulse" binary, not processes running FROM /opt/pulse
+pkill -x "pulse" 2>/dev/null
# Start backend on port 7656 (one port up from normal)
echo "Starting backend on port 7656..."
diff --git a/internal/api/auth.go b/internal/api/auth.go
index 63f4a7c20..9f32047b8 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -4,6 +4,7 @@ import (
cryptorand "crypto/rand"
"encoding/base64"
"encoding/hex"
+ "fmt"
"net/http"
"strings"
"sync"
@@ -63,11 +64,68 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
return true
}
- log.Debug().
+ // API-only mode: when only API token is configured (no password auth)
+ // Allow read-only endpoints for the UI to work
+ if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken != "" {
+ // Check if an API token was provided
+ providedToken := r.Header.Get("X-API-Token")
+ if providedToken == "" {
+ providedToken = r.URL.Query().Get("token")
+ }
+
+ // If a token was provided, validate it
+ if providedToken != "" {
+ if providedToken == cfg.APIToken {
+ return true
+ }
+ // Invalid token provided
+ if w != nil {
+ http.Error(w, "Invalid API token", http.StatusUnauthorized)
+ }
+ return false
+ }
+
+ // No token provided - allow read-only endpoints for UI
+ if r.Method == "GET" || r.URL.Path == "/ws" {
+ // Allow these endpoints without auth for UI to function
+ allowedPaths := []string{
+ "/api/state",
+ "/api/config/nodes",
+ "/api/config/system",
+ "/api/settings",
+ "/api/discover",
+ "/api/security/status",
+ "/api/version",
+ "/api/health",
+ "/api/updates/check",
+ "/api/system/diagnostics",
+ "/api/guests/metadata",
+ "/ws", // WebSocket for real-time updates
+ }
+ for _, path := range allowedPaths {
+ if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path+"/") {
+ log.Debug().Str("path", r.URL.Path).Msg("Allowing read-only access in API-only mode")
+ return true
+ }
+ }
+ }
+
+ // Require token for everything else
+ if w != nil {
+ w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
+ http.Error(w, "API token required", http.StatusUnauthorized)
+ }
+ return false
+ }
+
+ log.Info().
Str("configured_user", cfg.AuthUser).
Bool("has_pass", cfg.AuthPass != "").
Bool("has_token", cfg.APIToken != "").
+ Str("api_token_length", fmt.Sprintf("%d", len(cfg.APIToken))).
+ Str("api_token_first_chars", fmt.Sprintf("%.10s...", cfg.APIToken)).
Str("url", r.URL.Path).
+ Str("provided_token", r.Header.Get("X-API-Token")).
Msg("Checking authentication")
// Check API token first (for backward compatibility)
@@ -75,7 +133,13 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
// Check header
if token := r.Header.Get("X-API-Token"); token != "" {
// Check if stored token is hashed or plain text
- if internalauth.IsAPITokenHashed(cfg.APIToken) {
+ isHashed := internalauth.IsAPITokenHashed(cfg.APIToken)
+ log.Info().
+ Bool("is_hashed", isHashed).
+ Bool("tokens_match", token == cfg.APIToken).
+ Msg("Comparing API tokens")
+
+ if isHashed {
// Compare against hash
if internalauth.CompareAPIToken(token, cfg.APIToken) {
return true
diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go
index 36a7380a6..320686494 100644
--- a/internal/api/config_handlers.go
+++ b/internal/api/config_handlers.go
@@ -1365,6 +1365,8 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
// Get current values from running config
settings := config.SystemSettings{
PollingInterval: int(h.config.PollingInterval.Seconds()),
+ PVEPollingInterval: int(h.config.PVEPollingInterval.Seconds()),
+ PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()),
BackendPort: h.config.BackendPort,
FrontendPort: h.config.FrontendPort,
AllowedOrigins: h.config.AllowedOrigins,
@@ -1387,24 +1389,43 @@ func (h *ConfigHandlers) HandleUpdateSystemSettings(w http.ResponseWriter, r *ht
return
}
- // Update polling interval using our persistence
+ // Update polling intervals
+ needsReload := false
+
+ // Handle PVE polling interval
+ if settings.PVEPollingInterval > 0 {
+ h.config.PVEPollingInterval = time.Duration(settings.PVEPollingInterval) * time.Second
+ needsReload = true
+ } else if settings.PollingInterval > 0 {
+ // Fallback to legacy interval
+ h.config.PVEPollingInterval = time.Duration(settings.PollingInterval) * time.Second
+ needsReload = true
+ }
+
+ // Handle PBS polling interval
+ if settings.PBSPollingInterval > 0 {
+ h.config.PBSPollingInterval = time.Duration(settings.PBSPollingInterval) * time.Second
+ needsReload = true
+ } else if settings.PollingInterval > 0 {
+ // Fallback to legacy interval
+ h.config.PBSPollingInterval = time.Duration(settings.PollingInterval) * time.Second
+ needsReload = true
+ }
+
+ // Keep legacy interval updated for compatibility
if settings.PollingInterval > 0 {
- if err := config.UpdatePollingInterval(settings.PollingInterval); err != nil {
- log.Error().Err(err).Msg("Failed to save polling interval")
- http.Error(w, "Failed to save settings", http.StatusInternalServerError)
- return
- }
-
- // Update the running config
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
-
- // Trigger a monitor reload to apply the new polling interval
- if h.reloadFunc != nil {
- log.Info().Int("interval", settings.PollingInterval).Msg("Triggering monitor reload for new polling interval")
- if err := h.reloadFunc(); err != nil {
- log.Error().Err(err).Msg("Failed to reload monitor with new polling interval")
- // Don't fail the request, the setting was saved
- }
+ }
+
+ // Trigger a monitor reload if intervals changed
+ if needsReload && h.reloadFunc != nil {
+ log.Info().
+ Int("pveInterval", settings.PVEPollingInterval).
+ Int("pbsInterval", settings.PBSPollingInterval).
+ Msg("Triggering monitor reload for new polling intervals")
+ if err := h.reloadFunc(); err != nil {
+ log.Error().Err(err).Msg("Failed to reload monitor with new polling intervals")
+ // Don't fail the request, the setting was saved
}
}
diff --git a/internal/api/router.go b/internal/api/router.go
index 1de1ab409..91bf6faf5 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -2,7 +2,6 @@ package api
import (
"bufio"
- base64Pkg "encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -191,14 +190,17 @@ func (r *Router) setupRoutes() {
// Security routes
r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword)
- r.mux.HandleFunc("/api/security/remove-password", r.handleRemovePassword)
r.mux.HandleFunc("/api/logout", r.handleLogout)
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
// Check for basic auth configuration
- hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" || os.Getenv("REQUIRE_AUTH") == "true"
+ // Check both environment variables and loaded config
+ hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" ||
+ os.Getenv("REQUIRE_AUTH") == "true" ||
+ r.config.AuthUser != "" ||
+ r.config.AuthPass != ""
// Check if .env file exists but hasn't been loaded yet (pending restart)
configuredButPendingRestart := false
@@ -236,8 +238,15 @@ func (r *Router) setupRoutes() {
}
isTrustedNetwork := utils.IsTrustedNetwork(clientIP, trustedNetworks)
+ // Create token hint if token exists
+ var apiTokenHint string
+ if r.config.APIToken != "" && len(r.config.APIToken) >= 8 {
+ apiTokenHint = r.config.APIToken[:4] + "..." + r.config.APIToken[len(r.config.APIToken)-4:]
+ }
+
status := map[string]interface{}{
"apiTokenConfigured": r.config.APIToken != "",
+ "apiTokenHint": apiTokenHint,
"requiresAuth": r.config.APIToken != "",
"exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
"unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
@@ -983,94 +992,6 @@ PULSE_AUTH_PASS='%s'
}
}
-// handleRemovePassword handles password removal requests
-func (r *Router) handleRemovePassword(w http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
- "Only POST method is allowed", nil)
- return
- }
-
- // Parse request
- var removeReq struct {
- CurrentPassword string `json:"currentPassword"`
- }
-
- if err := json.NewDecoder(req.Body).Decode(&removeReq); err != nil {
- writeErrorResponse(w, http.StatusBadRequest, "invalid_request",
- "Invalid request body", nil)
- return
- }
-
- // Verify current password matches
- auth := req.Header.Get("Authorization")
- if auth == "" {
- // Try the provided password
- if removeReq.CurrentPassword == "" {
- writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
- "Current password required", nil)
- return
- }
- // Create basic auth header from provided password
- credentials := base64Pkg.StdEncoding.EncodeToString([]byte(r.config.AuthUser + ":" + removeReq.CurrentPassword))
- req.Header.Set("Authorization", "Basic "+credentials)
- }
-
- // Verify authentication
- if !CheckAuth(r.config, nil, req) {
- writeErrorResponse(w, http.StatusUnauthorized, "invalid_password",
- "Current password is incorrect", nil)
- return
- }
-
- // Clear environment variables
- os.Unsetenv("PULSE_AUTH_USER")
- os.Unsetenv("PULSE_AUTH_PASS")
- os.Unsetenv("PULSE_PASSWORD")
- os.Unsetenv("API_TOKEN")
-
- // Clear all authentication from running config
- r.config.AuthUser = ""
- r.config.AuthPass = ""
- r.config.APIToken = ""
-
- // Try to run the remove-password script with sudo
- // This will remove the password from systemd configuration
- scriptPath := "/opt/pulse/scripts/remove-password.sh"
- if _, err := os.Stat(scriptPath); err == nil {
- cmd := exec.Command("sudo", "-n", scriptPath)
- if _, err := cmd.CombinedOutput(); err != nil {
- log.Warn().Err(err).Msg("Could not run remove-password script with sudo")
- } else {
- log.Info().Msg("Successfully removed password from systemd")
- }
- }
-
- // Save the config without authentication
- if err := config.SaveConfig(r.config); err != nil {
- log.Error().Err(err).Msg("Failed to save config after removing password")
- }
-
- log.Info().Msg("Password authentication removed successfully")
-
- // Invalidate all sessions (forces logout)
- InvalidateUserSessions("admin")
-
- // Audit log password removal
- LogAuditEvent("password_removed", "admin", GetClientIP(req), req.URL.Path, true, "Password authentication disabled")
-
- // Return success
- message := "All authentication removed successfully. Pulse is now running without any authentication."
- requiresManualStep := false
-
- // Return success
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": true,
- "message": message,
- "requiresManualStep": requiresManualStep,
- })
-}
// handleLogout handles logout requests
func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) {
diff --git a/internal/api/security.go b/internal/api/security.go
index c598a5ec0..d859fb2c0 100644
--- a/internal/api/security.go
+++ b/internal/api/security.go
@@ -84,6 +84,11 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
return true
}
+ // Skip CSRF for Basic Auth (doesn't use sessions, not vulnerable to CSRF)
+ if r.Header.Get("Authorization") != "" {
+ return true
+ }
+
// Get session from cookie
cookie, err := r.Cookie("pulse_session")
if err != nil {
diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go
index 281e5d36d..308616fa4 100644
--- a/internal/api/security_setup_fix.go
+++ b/internal/api/security_setup_fix.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rs/zerolog/log"
)
@@ -61,13 +62,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return
}
- // Check if auth is already configured (for ProxmoxVE script compatibility)
- if r.config.APIToken != "" || r.config.AuthUser != "" {
- log.Info().Msg("Security setup skipped - auth already configured")
+ // Check if password auth is already configured
+ // Allow adding password auth on top of API-only access
+ if r.config.AuthUser != "" && r.config.AuthPass != "" {
+ log.Info().Msg("Security setup skipped - password auth already configured")
response := map[string]interface{}{
"success": true,
"skipped": true,
- "message": "Security is already configured. No changes made.",
+ "message": "Password authentication is already configured. Please remove existing security first if you want to reconfigure.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
@@ -76,9 +78,12 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
// Parse request body
var setupRequest struct {
- Username string `json:"username"`
- Password string `json:"password"`
- APIToken string `json:"apiToken"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ APIToken string `json:"apiToken"`
+ PollingInterval int `json:"pollingInterval"`
+ EnableNotifications bool `json:"enableNotifications"`
+ DarkMode bool `json:"darkMode"`
}
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
@@ -92,6 +97,11 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return
}
+ // Set default polling interval if not provided
+ if setupRequest.PollingInterval == 0 {
+ setupRequest.PollingInterval = 5
+ }
+
// Hash the password
hashedPassword, err := auth.HashPassword(setupRequest.Password)
if err != nil {
@@ -107,8 +117,34 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return
}
- // Hash the API token
- hashedToken := auth.HashAPIToken(setupRequest.APIToken)
+ // Don't hash the API token - store it as plain text
+ // (Hashing causes issues with token length detection)
+ // Always use the new token when setting up full security
+ // This ensures any API-only tokens are replaced with the new secure token
+ apiToken := setupRequest.APIToken
+ if r.config.APIToken != "" && r.config.AuthUser == "" && r.config.AuthPass == "" {
+ // We had API-only access before, now replacing with full security
+ log.Info().Msg("Replacing API-only token with new secure token")
+ }
+
+ // Update runtime config immediately - no restart needed!
+ r.config.AuthUser = setupRequest.Username
+ r.config.AuthPass = hashedPassword
+ r.config.APIToken = apiToken
+ r.config.APITokenEnabled = true
+ r.config.PollingInterval = time.Duration(setupRequest.PollingInterval) * time.Second
+ log.Info().Msg("Runtime config updated with new security settings - active immediately")
+
+ // Save system settings to system.json (polling interval, etc)
+ systemSettings := config.SystemSettings{
+ PollingInterval: setupRequest.PollingInterval,
+ ConnectionTimeout: 10, // Default
+ AutoUpdateEnabled: false, // Default
+ }
+ if err := r.persistence.SaveSystemSettings(systemSettings); err != nil {
+ log.Error().Err(err).Msg("Failed to save system settings")
+ // Continue anyway - not critical for auth setup
+ }
// Detect environment
isSystemd := os.Getenv("INVOCATION_ID") != ""
@@ -133,9 +169,9 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
# IMPORTANT: Do not remove the single quotes around the password hash!
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
-API_TOKEN='%s'
+API_TOKEN=%s
ENABLE_AUDIT_LOG=true
-`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken)
+`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
// Ensure directory exists
os.MkdirAll(r.config.ConfigPath, 0755)
@@ -152,9 +188,9 @@ ENABLE_AUDIT_LOG=true
"success": true,
"method": "docker",
"deploymentType": "docker",
- "requiresManualRestart": true,
- "message": "Security configuration saved. Restart your Docker container to apply settings.",
- "note": "Your credentials have been saved to /data/.env and will persist after restart.",
+ "requiresManualRestart": false,
+ "message": "Security enabled immediately! Your settings are saved and active.",
+ "note": "Configuration saved to /data/.env for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
@@ -169,9 +205,9 @@ ENABLE_AUDIT_LOG=true
# Generated on %s
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
-API_TOKEN='%s'
+API_TOKEN=%s
ENABLE_AUDIT_LOG=true
-`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken)
+`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
// Save to config directory (usually /etc/pulse)
os.MkdirAll(r.config.ConfigPath, 0755)
@@ -187,16 +223,16 @@ ENABLE_AUDIT_LOG=true
}
}
- // Create manual instructions for the user
+ // Create response - security is active immediately
response := map[string]interface{}{
"success": true,
"method": "systemd-nonroot",
"serviceName": serviceName,
"envFile": envPath,
"deploymentType": updates.GetDeploymentType(),
- "message": fmt.Sprintf("Security settings saved to %s. Restart the %s service to apply.", envPath, serviceName),
- "command": fmt.Sprintf("sudo systemctl restart %s", serviceName),
- "note": "You may need root privileges to restart the service.",
+ "requiresManualRestart": false,
+ "message": "Security enabled immediately! Your settings are saved and active.",
+ "note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
@@ -222,7 +258,7 @@ Environment="PULSE_AUTH_USER=%s"
Environment="PULSE_AUTH_PASS=%s"
Environment="API_TOKEN=%s"
Environment="ENABLE_AUDIT_LOG=true"
-`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken)
+`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil {
log.Error().Err(err).Msg("Failed to write systemd override")
@@ -239,9 +275,9 @@ Environment="ENABLE_AUDIT_LOG=true"
"serviceName": serviceName,
"deploymentType": updates.GetDeploymentType(),
"automatic": true,
- "readyToRestart": true,
- "message": fmt.Sprintf("Security configured! Restart %s service to apply settings.", serviceName),
- "command": fmt.Sprintf("systemctl restart %s", serviceName),
+ "requiresManualRestart": false,
+ "message": "Security enabled immediately! Your settings are saved and active.",
+ "note": "Systemd override created for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
@@ -258,9 +294,9 @@ Environment="ENABLE_AUDIT_LOG=true"
# Generated on %s
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
-API_TOKEN='%s'
+API_TOKEN=%s
ENABLE_AUDIT_LOG=true
-`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken)
+`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
// Try to create directory if needed
os.MkdirAll(filepath.Dir(envPath), 0755)
@@ -278,8 +314,9 @@ ENABLE_AUDIT_LOG=true
"method": "manual",
"envFile": envPath,
"deploymentType": deploymentType,
- "message": "Security configuration saved. Restart Pulse to apply settings.",
- "note": fmt.Sprintf("Configuration saved to %s", envPath),
+ "requiresManualRestart": false,
+ "message": "Security enabled immediately! Your settings are saved and active.",
+ "note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
@@ -290,8 +327,9 @@ ENABLE_AUDIT_LOG=true
// HandleRegenerateAPIToken generates a new API token and updates the .env file
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
- // Require authentication
- if !CheckAuth(r.config, w, rq) {
+ // Only require authentication if auth is already configured
+ // This allows users to set up API-only access without password auth
+ if (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
return
}
@@ -300,8 +338,8 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
return
}
- // Generate new token
- tokenBytes := make([]byte, 32)
+ // Generate new token (24 bytes = 48 hex chars, not 64 to avoid hash detection issue)
+ tokenBytes := make([]byte, 24)
if _, err := rand.Read(tokenBytes); err != nil {
log.Error().Err(err).Msg("Failed to generate random token")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
@@ -309,6 +347,11 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
}
newToken := hex.EncodeToString(tokenBytes)
+ // Update runtime config immediately - no restart needed!
+ r.config.APIToken = newToken
+ r.config.APITokenEnabled = true
+ log.Info().Msg("Runtime config updated with new API token - active immediately")
+
// Determine env file path
envPath := filepath.Join(r.config.ConfigPath, ".env")
if r.config.ConfigPath == "" {
@@ -333,7 +376,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
var updated bool
for i, line := range lines {
if strings.HasPrefix(line, "API_TOKEN=") {
- lines[i] = fmt.Sprintf("API_TOKEN='%s'", newToken)
+ lines[i] = fmt.Sprintf("API_TOKEN=%s", newToken)
updated = true
break
}
@@ -341,7 +384,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
if !updated {
// API_TOKEN line not found, add it
- lines = append(lines, fmt.Sprintf("API_TOKEN='%s'", newToken))
+ lines = append(lines, fmt.Sprintf("API_TOKEN=%s", newToken))
}
// Write updated content back
@@ -361,8 +404,8 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
"success": true,
"token": newToken,
"deploymentType": deploymentType,
- "requiresRestart": true,
- "message": "New API token generated. Restart required to activate.",
+ "requiresRestart": false,
+ "message": "New API token generated and active immediately!",
}
w.Header().Set("Content-Type", "application/json")
diff --git a/internal/config/config.go b/internal/config/config.go
index b8319c1b9..162a3d118 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,3 +1,12 @@
+// Package config manages Pulse configuration from multiple sources.
+//
+// Configuration File Separation:
+// - .env: Authentication credentials ONLY (PULSE_AUTH_USER, PULSE_AUTH_PASS, API_TOKEN)
+// - system.json: Application settings (polling interval, timeouts, update settings, etc.)
+// - nodes.enc: Encrypted node credentials (PVE/PBS passwords and tokens)
+//
+// This separation ensures security, clarity, and proper access control.
+// See docs/CONFIGURATION.md for detailed documentation.
package config
import (
@@ -56,7 +65,9 @@ type Config struct {
PBSInstances []PBSInstance
// Monitoring settings
- PollingInterval time.Duration `envconfig:"POLLING_INTERVAL"` // Loaded from system.json
+ PollingInterval time.Duration `envconfig:"POLLING_INTERVAL"` // Deprecated - ignored, always 10s
+ PVEPollingInterval time.Duration `envconfig:"PVE_POLLING_INTERVAL"` // Deprecated - ignored, always 10s
+ PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"10s"`
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
@@ -72,6 +83,7 @@ type Config struct {
// Security settings
APIToken string `envconfig:"API_TOKEN"`
+ APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"`
AuthUser string `envconfig:"PULSE_AUTH_USER"`
AuthPass string `envconfig:"PULSE_AUTH_PASS"`
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
@@ -185,13 +197,14 @@ func Load() (*Config, error) {
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
- PollingInterval: 3 * time.Second,
+ PollingInterval: 10 * time.Second, // Deprecated - not used
+ PVEPollingInterval: 10 * time.Second, // Deprecated - not used
+ PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
DiscoverySubnet: "auto",
}
// Initialize persistence
persistence := NewConfigPersistence(dataDir)
- hasSystemConfig := false
if persistence != nil {
// Store global persistence for saving
globalPersistence = persistence
@@ -209,10 +222,26 @@ func Load() (*Config, error) {
// Load system configuration
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
- hasSystemConfig = true
+ // Handle new separate intervals
+ if systemSettings.PVEPollingInterval > 0 {
+ cfg.PVEPollingInterval = time.Duration(systemSettings.PVEPollingInterval) * time.Second
+ } else if systemSettings.PollingInterval > 0 {
+ // Fallback to legacy interval for PVE
+ cfg.PVEPollingInterval = time.Duration(systemSettings.PollingInterval) * time.Second
+ }
+
+ if systemSettings.PBSPollingInterval > 0 {
+ cfg.PBSPollingInterval = time.Duration(systemSettings.PBSPollingInterval) * time.Second
+ } else if systemSettings.PollingInterval > 0 {
+ // Fallback to legacy interval for PBS
+ cfg.PBSPollingInterval = time.Duration(systemSettings.PollingInterval) * time.Second
+ }
+
+ // Keep legacy field for compatibility
if systemSettings.PollingInterval > 0 {
cfg.PollingInterval = time.Duration(systemSettings.PollingInterval) * time.Second
}
+
if systemSettings.UpdateChannel != "" {
cfg.UpdateChannel = systemSettings.UpdateChannel
}
@@ -229,11 +258,43 @@ func Load() (*Config, error) {
if systemSettings.ConnectionTimeout > 0 {
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
}
+ if systemSettings.LogLevel != "" {
+ cfg.LogLevel = systemSettings.LogLevel
+ }
+ if systemSettings.DiscoverySubnet != "" {
+ cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
+ }
// APIToken no longer loaded from system.json - only from .env
log.Info().
Dur("interval", cfg.PollingInterval).
Str("updateChannel", cfg.UpdateChannel).
+ Str("logLevel", cfg.LogLevel).
Msg("Loaded system configuration")
+ } else {
+ // No system.json exists - create default one
+ log.Info().Msg("No system.json found, creating default")
+ defaultSettings := SystemSettings{
+ PollingInterval: int(cfg.PollingInterval.Seconds()),
+ ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
+ AutoUpdateEnabled: false,
+ }
+ if err := persistence.SaveSystemSettings(defaultSettings); err != nil {
+ log.Warn().Err(err).Msg("Failed to create default system.json")
+ }
+ }
+ }
+
+ // Ensure new polling intervals have defaults if not set
+ if cfg.PVEPollingInterval == 0 {
+ cfg.PVEPollingInterval = cfg.PollingInterval
+ if cfg.PVEPollingInterval == 0 {
+ cfg.PVEPollingInterval = 5 * time.Second
+ }
+ }
+ if cfg.PBSPollingInterval == 0 {
+ cfg.PBSPollingInterval = cfg.PollingInterval
+ if cfg.PBSPollingInterval == 0 {
+ cfg.PBSPollingInterval = 60 * time.Second
}
}
@@ -255,7 +316,16 @@ func Load() (*Config, error) {
}
if apiToken := os.Getenv("API_TOKEN"); apiToken != "" {
cfg.APIToken = apiToken
- log.Info().Msg("Overriding API token from env var")
+ log.Info().Msg("Loaded API token from env var")
+ }
+ // Check if API token is enabled
+ if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" {
+ cfg.APITokenEnabled = apiTokenEnabled == "true" || apiTokenEnabled == "1"
+ log.Info().Bool("enabled", cfg.APITokenEnabled).Msg("API token enabled status from env var")
+ } else if cfg.APIToken != "" {
+ // If token exists but no explicit enabled flag, assume enabled for backwards compatibility
+ cfg.APITokenEnabled = true
+ log.Info().Msg("API token exists without explicit enabled flag, assuming enabled for backwards compatibility")
}
if authUser := os.Getenv("PULSE_AUTH_USER"); authUser != "" {
cfg.AuthUser = authUser
@@ -276,52 +346,11 @@ func Load() (*Config, error) {
}
log.Info().Bool("is_hashed", IsPasswordHashed(authPass)).Int("length", len(authPass)).Msg("Loaded auth password from env var")
}
- if updateChannel := os.Getenv("UPDATE_CHANNEL"); updateChannel != "" {
- cfg.UpdateChannel = updateChannel
- log.Info().Str("channel", updateChannel).Msg("Overriding update channel from env var")
- } else if updateChannel := os.Getenv("PULSE_UPDATE_CHANNEL"); updateChannel != "" {
- cfg.UpdateChannel = updateChannel
- log.Info().Str("channel", updateChannel).Msg("Overriding update channel from PULSE_ env var")
- }
+ // REMOVED: Update channel, auto-update, connection timeout, and allowed origins env vars
+ // These settings now ONLY come from system.json to prevent confusion
+ // Only keeping essential deployment/infrastructure env vars
- // Auto-update settings from env vars
- if autoUpdateEnabled := os.Getenv("AUTO_UPDATE_ENABLED"); autoUpdateEnabled != "" {
- cfg.AutoUpdateEnabled = autoUpdateEnabled == "true" || autoUpdateEnabled == "1"
- log.Info().Bool("enabled", cfg.AutoUpdateEnabled).Msg("Overriding auto-update enabled from env var")
- }
- if interval := os.Getenv("AUTO_UPDATE_CHECK_INTERVAL"); interval != "" {
- if i, err := strconv.Atoi(interval); err == nil && i > 0 {
- cfg.AutoUpdateCheckInterval = time.Duration(i) * time.Hour
- log.Info().Int("hours", i).Msg("Overriding auto-update check interval from env var")
- }
- }
- if updateTime := os.Getenv("AUTO_UPDATE_TIME"); updateTime != "" {
- cfg.AutoUpdateTime = updateTime
- log.Info().Str("time", updateTime).Msg("Overriding auto-update time from env var")
- }
-
- // Other settings from env vars - only use if not already set from system.json
- if pollingInterval := os.Getenv("POLLING_INTERVAL"); pollingInterval != "" {
- // Only use env var if system.json doesn't exist (for backwards compatibility)
- if !hasSystemConfig {
- if i, err := strconv.Atoi(pollingInterval); err == nil && i > 0 {
- cfg.PollingInterval = time.Duration(i) * time.Second
- log.Info().Int("seconds", i).Msg("Using polling interval from env var (no system.json exists)")
- }
- } else {
- log.Debug().Str("env_value", pollingInterval).Msg("Ignoring POLLING_INTERVAL env var - using system.json value")
- }
- }
- if connectionTimeout := os.Getenv("CONNECTION_TIMEOUT"); connectionTimeout != "" {
- if i, err := strconv.Atoi(connectionTimeout); err == nil && i > 0 {
- cfg.ConnectionTimeout = time.Duration(i) * time.Second
- log.Info().Int("seconds", i).Msg("Overriding connection timeout from env var")
- }
- }
- if allowedOrigins := os.Getenv("ALLOWED_ORIGINS"); allowedOrigins != "" {
- cfg.AllowedOrigins = allowedOrigins
- log.Info().Str("origins", allowedOrigins).Msg("Overriding allowed origins from env var")
- } else if cfg.AllowedOrigins == "" {
+ if cfg.AllowedOrigins == "" {
// If not configured and we're in development mode (different ports for frontend/backend)
// allow localhost for development convenience
if os.Getenv("NODE_ENV") == "development" || os.Getenv("PULSE_DEV") == "true" {
@@ -329,16 +358,8 @@ func Load() (*Config, error) {
log.Info().Msg("Development mode: allowing localhost origins")
}
}
- if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
- cfg.LogLevel = logLevel
- log.Info().Str("level", logLevel).Msg("Overriding log level from env var")
- }
-
- // Discovery settings from env vars
- if discoverySubnet := os.Getenv("DISCOVERY_SUBNET"); discoverySubnet != "" {
- cfg.DiscoverySubnet = discoverySubnet
- log.Info().Str("subnet", discoverySubnet).Msg("Overriding discovery subnet from env var")
- }
+ // REMOVED: LOG_LEVEL and DISCOVERY_SUBNET env vars
+ // These settings now ONLY come from system.json to prevent confusion
// Set log level
switch cfg.LogLevel {
@@ -380,7 +401,9 @@ func SaveConfig(cfg *Config) error {
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
- APIToken: cfg.APIToken,
+ LogLevel: cfg.LogLevel,
+ DiscoverySubnet: cfg.DiscoverySubnet,
+ // APIToken removed - now handled via .env only
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
return fmt.Errorf("failed to save system config: %w", err)
diff --git a/internal/config/persistence.go b/internal/config/persistence.go
index b5af999d5..0a27d2adc 100644
--- a/internal/config/persistence.go
+++ b/internal/config/persistence.go
@@ -286,7 +286,9 @@ type NodesConfig struct {
// SystemSettings represents system configuration settings
type SystemSettings struct {
- PollingInterval int `json:"pollingInterval"`
+ PollingInterval int `json:"pollingInterval"` // Legacy - kept for compatibility
+ PVEPollingInterval int `json:"pvePollingInterval"` // Proxmox polling interval in seconds
+ PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"`
@@ -295,6 +297,8 @@ type SystemSettings struct {
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
+ LogLevel string `json:"logLevel,omitempty"`
+ DiscoverySubnet string `json:"discoverySubnet,omitempty"`
// APIToken removed - now handled via .env file only
}
@@ -446,10 +450,8 @@ func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
data, err := os.ReadFile(c.systemFile)
if err != nil {
if os.IsNotExist(err) {
- // Return default settings if file doesn't exist
- return &SystemSettings{
- PollingInterval: 5,
- }, nil
+ // Return nil if file doesn't exist - let env vars take precedence
+ return nil, nil
}
return nil, err
}
diff --git a/internal/config/watcher.go b/internal/config/watcher.go
new file mode 100644
index 000000000..5d44294de
--- /dev/null
+++ b/internal/config/watcher.go
@@ -0,0 +1,218 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/joho/godotenv"
+ "github.com/rs/zerolog/log"
+)
+
+// ConfigWatcher monitors the .env file for changes and updates runtime config
+type ConfigWatcher struct {
+ config *Config
+ envPath string
+ watcher *fsnotify.Watcher
+ stopChan chan struct{}
+ lastModTime time.Time
+ mu sync.RWMutex
+}
+
+// NewConfigWatcher creates a new config watcher
+func NewConfigWatcher(config *Config) (*ConfigWatcher, error) {
+ // Determine env file path
+ envPath := filepath.Join(config.ConfigPath, ".env")
+ if config.ConfigPath == "" {
+ envPath = "/etc/pulse/.env"
+ }
+
+ // Check for Docker environment
+ if _, err := os.Stat("/data/.env"); err == nil {
+ envPath = "/data/.env"
+ }
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, err
+ }
+
+ cw := &ConfigWatcher{
+ config: config,
+ envPath: envPath,
+ watcher: watcher,
+ stopChan: make(chan struct{}),
+ }
+
+ // Get initial mod time
+ if stat, err := os.Stat(envPath); err == nil {
+ cw.lastModTime = stat.ModTime()
+ }
+
+ return cw, nil
+}
+
+// Start begins watching the config file
+func (cw *ConfigWatcher) Start() error {
+ // Watch the directory instead of the file directly
+ // This handles atomic writes better (editor save patterns)
+ dir := filepath.Dir(cw.envPath)
+ err := cw.watcher.Add(dir)
+ if err != nil {
+ log.Warn().Err(err).Str("path", dir).Msg("Failed to watch config directory, falling back to polling")
+ // Fall back to polling if watch fails
+ go cw.pollForChanges()
+ return nil
+ }
+
+ go cw.watchForChanges()
+ log.Info().Str("path", cw.envPath).Msg("Started watching .env file for changes")
+ return nil
+}
+
+// Stop stops the config watcher
+func (cw *ConfigWatcher) Stop() {
+ close(cw.stopChan)
+ cw.watcher.Close()
+}
+
+// ReloadConfig manually triggers a config reload (e.g., from SIGHUP)
+func (cw *ConfigWatcher) ReloadConfig() {
+ cw.reloadConfig()
+}
+
+// watchForChanges handles fsnotify events
+func (cw *ConfigWatcher) watchForChanges() {
+ for {
+ select {
+ case event, ok := <-cw.watcher.Events:
+ if !ok {
+ return
+ }
+
+ // Check if the event is for our .env file
+ if filepath.Base(event.Name) == ".env" || event.Name == cw.envPath {
+ // Debounce - wait a bit for write to complete
+ time.Sleep(100 * time.Millisecond)
+
+ if event.Op&(fsnotify.Write|fsnotify.Create) != 0 {
+ log.Info().Str("event", event.Op.String()).Msg("Detected .env file change")
+ cw.reloadConfig()
+ }
+ }
+
+ case err, ok := <-cw.watcher.Errors:
+ if !ok {
+ return
+ }
+ log.Error().Err(err).Msg("Config watcher error")
+
+ case <-cw.stopChan:
+ return
+ }
+ }
+}
+
+// pollForChanges is a fallback that polls for changes
+func (cw *ConfigWatcher) pollForChanges() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ if stat, err := os.Stat(cw.envPath); err == nil {
+ if stat.ModTime().After(cw.lastModTime) {
+ log.Info().Msg("Detected .env file change via polling")
+ cw.lastModTime = stat.ModTime()
+ cw.reloadConfig()
+ }
+ }
+
+ case <-cw.stopChan:
+ return
+ }
+ }
+}
+
+// reloadConfig reloads the config from the .env file
+func (cw *ConfigWatcher) reloadConfig() {
+ cw.mu.Lock()
+ defer cw.mu.Unlock()
+
+ // Load the .env file
+ envMap, err := godotenv.Read(cw.envPath)
+ if err != nil {
+ // File might not exist, which is fine (no auth)
+ if !os.IsNotExist(err) {
+ log.Error().Err(err).Msg("Failed to read .env file")
+ return
+ }
+ envMap = make(map[string]string)
+ }
+
+ // Track what changed
+ var changes []string
+
+ // Update auth settings
+ oldAuthUser := cw.config.AuthUser
+ oldAuthPass := cw.config.AuthPass
+ oldAPIToken := cw.config.APIToken
+
+ // Apply auth user
+ newUser := strings.Trim(envMap["PULSE_AUTH_USER"], "'\"")
+ if newUser != oldAuthUser {
+ cw.config.AuthUser = newUser
+ if newUser == "" {
+ changes = append(changes, "auth user removed")
+ } else if oldAuthUser == "" {
+ changes = append(changes, "auth user added")
+ } else {
+ changes = append(changes, "auth user updated")
+ }
+ }
+
+ // Apply auth password
+ newPass := strings.Trim(envMap["PULSE_AUTH_PASS"], "'\"")
+ if newPass != oldAuthPass {
+ cw.config.AuthPass = newPass
+ if newPass == "" {
+ changes = append(changes, "auth password removed")
+ } else if oldAuthPass == "" {
+ changes = append(changes, "auth password added")
+ } else {
+ changes = append(changes, "auth password updated")
+ }
+ }
+
+ // Apply API token
+ newToken := strings.Trim(envMap["API_TOKEN"], "'\"")
+ if newToken != oldAPIToken {
+ cw.config.APIToken = newToken
+ cw.config.APITokenEnabled = (newToken != "")
+ if newToken == "" {
+ changes = append(changes, "API token removed")
+ } else if oldAPIToken == "" {
+ changes = append(changes, "API token added")
+ } else {
+ changes = append(changes, "API token updated")
+ }
+ }
+
+ // REMOVED: POLLING_INTERVAL from .env - now ONLY in system.json
+ // This prevents confusion and ensures single source of truth
+
+ // Log changes
+ if len(changes) > 0 {
+ log.Info().
+ Strs("changes", changes).
+ Bool("has_auth", cw.config.AuthUser != "" && cw.config.AuthPass != "").
+ Bool("has_token", cw.config.APIToken != "").
+ Msg("Applied .env file changes to runtime config")
+ } else {
+ log.Debug().Msg("No relevant changes detected in .env file")
+ }
+}
\ No newline at end of file
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index 77a170c59..71d244a65 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -36,6 +36,8 @@ type PVEClientInterface interface {
GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error)
GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error)
GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error)
+ GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error)
+ GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error)
}
// Monitor handles all monitoring operations
@@ -70,6 +72,14 @@ func safePercentage(used, total float64) float64 {
return result
}
+// maxInt64 returns the maximum of two int64 values
+func maxInt64(a, b int64) int64 {
+ if a > b {
+ return a
+ }
+ return b
+}
+
// safeFloat ensures a float value is not NaN or Inf
func safeFloat(val float64) float64 {
if math.IsNaN(val) || math.IsInf(val, 0) {
@@ -280,7 +290,7 @@ func New(cfg *config.Config) (*Monitor, error) {
// Start begins the monitoring loop
func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
log.Info().
- Dur("pollingInterval", m.config.PollingInterval).
+ Dur("pollingInterval", 10*time.Second).
Msg("Starting monitoring loop")
// Initialize and start discovery service
@@ -351,10 +361,12 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
})
// Create separate tickers for polling and broadcasting
- pollTicker := time.NewTicker(m.config.PollingInterval)
+ // Hardcoded to 10 seconds since Proxmox updates cluster/resources every 10 seconds
+ const pollingInterval = 10 * time.Second
+ pollTicker := time.NewTicker(pollingInterval)
defer pollTicker.Stop()
- broadcastTicker := time.NewTicker(m.config.PollingInterval)
+ broadcastTicker := time.NewTicker(pollingInterval)
defer broadcastTicker.Stop()
// Do an immediate poll on start
@@ -777,23 +789,22 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
}
}
- // Poll VMs if enabled
- if instanceCfg.MonitorVMs {
+ // Poll VMs and containers together using cluster/resources for efficiency
+ if instanceCfg.MonitorVMs || instanceCfg.MonitorContainers {
select {
case <-ctx.Done():
return
default:
- m.pollVMs(ctx, instanceName, client)
- }
- }
-
- // Poll containers if enabled
- if instanceCfg.MonitorContainers {
- select {
- case <-ctx.Done():
- return
- default:
- m.pollContainers(ctx, instanceName, client)
+ // Try to use efficient cluster/resources endpoint
+ if !m.pollVMsAndContainersEfficient(ctx, instanceName, client) {
+ // Fall back to old method if cluster/resources fails
+ if instanceCfg.MonitorVMs {
+ m.pollVMs(ctx, instanceName, client)
+ }
+ if instanceCfg.MonitorContainers {
+ m.pollContainers(ctx, instanceName, client)
+ }
+ }
}
}
@@ -841,6 +852,139 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
}
}
+// pollVMsAndContainersEfficient uses the cluster/resources endpoint to get all VMs and containers in one call
+func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceName string, client PVEClientInterface) bool {
+ log.Info().Str("instance", instanceName).Msg("Polling VMs and containers using cluster/resources")
+
+ // Get all resources in a single API call
+ resources, err := client.GetClusterResources(ctx, "vm")
+ if err != nil {
+ log.Debug().Err(err).Str("instance", instanceName).Msg("cluster/resources not available, falling back to traditional polling")
+ return false
+ }
+
+ var allVMs []models.VM
+ var allContainers []models.Container
+
+ for _, res := range resources {
+ guestID := fmt.Sprintf("%s-%s-%d", instanceName, res.Node, res.VMID)
+
+ // Calculate I/O rates
+ currentMetrics := IOMetrics{
+ DiskRead: int64(res.DiskRead),
+ DiskWrite: int64(res.DiskWrite),
+ NetworkIn: int64(res.NetIn),
+ NetworkOut: int64(res.NetOut),
+ Timestamp: time.Now(),
+ }
+ diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
+
+
+ if res.Type == "qemu" {
+ // Skip templates if configured
+ if res.Template == 1 {
+ continue
+ }
+
+ vm := models.VM{
+ ID: guestID,
+ VMID: res.VMID,
+ Name: res.Name,
+ Node: res.Node,
+ Instance: instanceName,
+ Status: res.Status,
+ CPU: safeFloat(res.CPU),
+ CPUs: res.MaxCPU,
+ Memory: models.Memory{
+ Total: int64(res.MaxMem),
+ Used: int64(res.Mem),
+ Free: int64(res.MaxMem - res.Mem),
+ Usage: safePercentage(float64(res.Mem), float64(res.MaxMem)),
+ },
+ Disk: models.Disk{
+ Total: int64(res.MaxDisk),
+ Used: int64(res.Disk),
+ Free: int64(res.MaxDisk - res.Disk),
+ Usage: safePercentage(float64(res.Disk), float64(res.MaxDisk)),
+ },
+ NetworkIn: maxInt64(0, int64(netInRate)),
+ NetworkOut: maxInt64(0, int64(netOutRate)),
+ DiskRead: maxInt64(0, int64(diskReadRate)),
+ DiskWrite: maxInt64(0, int64(diskWriteRate)),
+ Uptime: int64(res.Uptime),
+ Template: res.Template == 1,
+ LastSeen: time.Now(),
+ }
+
+ // Parse tags
+ if res.Tags != "" {
+ vm.Tags = strings.Split(res.Tags, ";")
+ }
+
+ allVMs = append(allVMs, vm)
+
+ } else if res.Type == "lxc" {
+ // Skip templates if configured
+ if res.Template == 1 {
+ continue
+ }
+
+ container := models.Container{
+ ID: guestID,
+ VMID: res.VMID,
+ Name: res.Name,
+ Node: res.Node,
+ Instance: instanceName,
+ Status: res.Status,
+ CPU: safeFloat(res.CPU),
+ CPUs: int(res.MaxCPU),
+ Memory: models.Memory{
+ Total: int64(res.MaxMem),
+ Used: int64(res.Mem),
+ Free: int64(res.MaxMem - res.Mem),
+ Usage: safePercentage(float64(res.Mem), float64(res.MaxMem)),
+ },
+ Disk: models.Disk{
+ Total: int64(res.MaxDisk),
+ Used: int64(res.Disk),
+ Free: int64(res.MaxDisk - res.Disk),
+ Usage: safePercentage(float64(res.Disk), float64(res.MaxDisk)),
+ },
+ NetworkIn: maxInt64(0, int64(netInRate)),
+ NetworkOut: maxInt64(0, int64(netOutRate)),
+ DiskRead: maxInt64(0, int64(diskReadRate)),
+ DiskWrite: maxInt64(0, int64(diskWriteRate)),
+ Uptime: int64(res.Uptime),
+ Template: res.Template == 1,
+ LastSeen: time.Now(),
+ }
+
+ // Parse tags
+ if res.Tags != "" {
+ container.Tags = strings.Split(res.Tags, ";")
+ }
+
+ allContainers = append(allContainers, container)
+ }
+ }
+
+ // Update state
+ if len(allVMs) > 0 {
+ m.state.UpdateVMsForInstance(instanceName, allVMs)
+ }
+ if len(allContainers) > 0 {
+ m.state.UpdateContainersForInstance(instanceName, allContainers)
+ }
+
+ log.Info().
+ Str("instance", instanceName).
+ Int("vms", len(allVMs)).
+ Int("containers", len(allContainers)).
+ Msg("VMs and containers polled efficiently with cluster/resources")
+
+ return true
+}
+
// pollVMs polls VMs from a PVE instance
func (m *Monitor) pollVMs(ctx context.Context, instanceName string, client PVEClientInterface) {
log.Debug().Str("instance", instanceName).Msg("Polling VMs")
@@ -947,10 +1091,10 @@ func (m *Monitor) pollVMs(ctx context.Context, instanceName string, client PVECl
Free: int64(vm.MaxDisk - vm.Disk),
Usage: safePercentage(float64(vm.Disk), float64(vm.MaxDisk)),
},
- NetworkIn: int64(netInRate),
- NetworkOut: int64(netOutRate),
- DiskRead: int64(diskReadRate),
- DiskWrite: int64(diskWriteRate),
+ NetworkIn: maxInt64(0, int64(netInRate)),
+ NetworkOut: maxInt64(0, int64(netOutRate)),
+ DiskRead: maxInt64(0, int64(diskReadRate)),
+ DiskWrite: maxInt64(0, int64(diskWriteRate)),
Uptime: int64(vm.Uptime),
Template: vm.Template == 1,
Tags: tags,
@@ -1062,10 +1206,10 @@ func (m *Monitor) pollContainers(ctx context.Context, instanceName string, clien
Free: int64(ct.MaxDisk - ct.Disk),
Usage: safePercentage(float64(ct.Disk), float64(ct.MaxDisk)),
},
- NetworkIn: int64(netInRate),
- NetworkOut: int64(netOutRate),
- DiskRead: int64(diskReadRate),
- DiskWrite: int64(diskWriteRate),
+ NetworkIn: maxInt64(0, int64(netInRate)),
+ NetworkOut: maxInt64(0, int64(netOutRate)),
+ DiskRead: maxInt64(0, int64(diskReadRate)),
+ DiskWrite: maxInt64(0, int64(diskWriteRate)),
Uptime: int64(ct.Uptime),
Template: ct.Template == 1,
Tags: tags,
diff --git a/internal/monitoring/ratetracker.go b/internal/monitoring/ratetracker.go
index 011314b89..463c79099 100644
--- a/internal/monitoring/ratetracker.go
+++ b/internal/monitoring/ratetracker.go
@@ -11,14 +11,24 @@ type IOMetrics = types.IOMetrics
// RateTracker tracks I/O metrics to calculate rates
type RateTracker struct {
- mu sync.RWMutex
- previous map[string]IOMetrics
+ mu sync.RWMutex
+ previous map[string]IOMetrics
+ lastRates map[string]RateCache
+}
+
+// RateCache stores the last calculated rates for a guest
+type RateCache struct {
+ DiskReadRate float64
+ DiskWriteRate float64
+ NetInRate float64
+ NetOutRate float64
}
// NewRateTracker creates a new rate tracker
func NewRateTracker() *RateTracker {
return &RateTracker{
- previous: make(map[string]IOMetrics),
+ previous: make(map[string]IOMetrics),
+ lastRates: make(map[string]RateCache),
}
}
@@ -29,16 +39,37 @@ func (rt *RateTracker) CalculateRates(guestID string, current IOMetrics) (diskRe
defer rt.mu.Unlock()
prev, exists := rt.previous[guestID]
- rt.previous[guestID] = current
-
+
if !exists {
- // No previous data, return -1 to indicate no data available
+ // No previous data, store it and return -1 to indicate no data available
+ rt.previous[guestID] = current
return -1, -1, -1, -1
}
+ // Check if the values have actually changed (detect stale data)
+ // If all cumulative values are the same, we're getting cached data from Proxmox
+ if current.DiskRead == prev.DiskRead &&
+ current.DiskWrite == prev.DiskWrite &&
+ current.NetworkIn == prev.NetworkIn &&
+ current.NetworkOut == prev.NetworkOut {
+ // Data hasn't changed - return last known good rates
+ if lastRate, hasRate := rt.lastRates[guestID]; hasRate {
+ return lastRate.DiskReadRate, lastRate.DiskWriteRate, lastRate.NetInRate, lastRate.NetOutRate
+ }
+ // No last rates available, return 0
+ return 0, 0, 0, 0
+ }
+
+ // Data has changed, update our cache
+ rt.previous[guestID] = current
+
// Calculate time difference in seconds
timeDiff := current.Timestamp.Sub(prev.Timestamp).Seconds()
if timeDiff <= 0 {
+ // Return last known rates if time hasn't advanced
+ if lastRate, hasRate := rt.lastRates[guestID]; hasRate {
+ return lastRate.DiskReadRate, lastRate.DiskWriteRate, lastRate.NetInRate, lastRate.NetOutRate
+ }
return 0, 0, 0, 0
}
@@ -56,6 +87,14 @@ func (rt *RateTracker) CalculateRates(guestID string, current IOMetrics) (diskRe
netOutRate = float64(current.NetworkOut-prev.NetworkOut) / timeDiff
}
+ // Cache the calculated rates
+ rt.lastRates[guestID] = RateCache{
+ DiskReadRate: diskReadRate,
+ DiskWriteRate: diskWriteRate,
+ NetInRate: netInRate,
+ NetOutRate: netOutRate,
+ }
+
return
}
@@ -64,4 +103,5 @@ func (rt *RateTracker) Clear() {
rt.mu.Lock()
defer rt.mu.Unlock()
rt.previous = make(map[string]IOMetrics)
+ rt.lastRates = make(map[string]RateCache)
}
\ No newline at end of file
diff --git a/internal/monitoring/reload.go b/internal/monitoring/reload.go
index ba4c30962..11c192d1e 100644
--- a/internal/monitoring/reload.go
+++ b/internal/monitoring/reload.go
@@ -187,4 +187,32 @@ func (rm *ReloadableMonitor) Stop() {
if rm.monitor != nil {
rm.monitor.Stop()
}
+}
+
+// UpdatePollingInterval updates just the polling interval without full reload
+func (rm *ReloadableMonitor) UpdatePollingInterval(interval time.Duration) {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ if rm.config.PollingInterval == interval {
+ return // No change
+ }
+
+ log.Info().
+ Dur("oldInterval", rm.config.PollingInterval).
+ Dur("newInterval", interval).
+ Msg("Updating polling interval via SIGHUP")
+
+ // Update config
+ rm.config.PollingInterval = interval
+ rm.monitor.config.PollingInterval = interval
+
+ // Cancel and restart the monitoring loop
+ if rm.cancel != nil {
+ rm.cancel()
+ }
+
+ // Start new monitoring loop with updated interval
+ rm.ctx, rm.cancel = context.WithCancel(rm.parentCtx)
+ go rm.monitor.Start(rm.ctx, rm.wsHub)
}
\ No newline at end of file
diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go
index dd104169d..d0ead149e 100644
--- a/pkg/proxmox/client.go
+++ b/pkg/proxmox/client.go
@@ -312,13 +312,35 @@ type Node struct {
Level string `json:"level"`
}
-// NodeStatus represents detailed node status
+// NodeStatus represents detailed node status from /nodes/{node}/status endpoint
+// This endpoint provides real-time metrics that update every second
type NodeStatus struct {
- LoadAvg []interface{} `json:"loadavg"` // Can be float64 or string
+ CPU float64 `json:"cpu"` // Real-time CPU usage (0-1)
+ Memory *MemoryStatus `json:"memory"` // Real-time memory stats
+ Swap *SwapStatus `json:"swap"` // Swap usage
+ LoadAvg []interface{} `json:"loadavg"` // Can be float64 or string
KernelVersion string `json:"kversion"`
PVEVersion string `json:"pveversion"`
CPUInfo *CPUInfo `json:"cpuinfo"`
RootFS *RootFS `json:"rootfs"`
+ Uptime uint64 `json:"uptime"` // Uptime in seconds
+ Wait float64 `json:"wait"` // IO wait
+ IODelay float64 `json:"iodelay"` // IO delay
+ Idle float64 `json:"idle"` // CPU idle time
+}
+
+// MemoryStatus represents real-time memory information
+type MemoryStatus struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+ Free uint64 `json:"free"`
+}
+
+// SwapStatus represents swap information
+type SwapStatus struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+ Free uint64 `json:"free"`
}
// RootFS represents root filesystem information
@@ -823,6 +845,72 @@ func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (*VMSta
return &result.Data, nil
}
+// GetContainerStatus returns detailed container status using real-time endpoint
+func (c *Client) GetContainerStatus(ctx context.Context, node string, vmid int) (*Container, error) {
+ resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", node, vmid))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var result struct {
+ Data Container `json:"data"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ return &result.Data, nil
+}
+
+// ClusterResource represents a resource from /cluster/resources
+type ClusterResource struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Node string `json:"node"`
+ Status string `json:"status"`
+ Name string `json:"name,omitempty"`
+ VMID int `json:"vmid,omitempty"`
+ CPU float64 `json:"cpu,omitempty"`
+ MaxCPU int `json:"maxcpu,omitempty"`
+ Mem uint64 `json:"mem,omitempty"`
+ MaxMem uint64 `json:"maxmem,omitempty"`
+ Disk uint64 `json:"disk,omitempty"`
+ MaxDisk uint64 `json:"maxdisk,omitempty"`
+ NetIn uint64 `json:"netin,omitempty"`
+ NetOut uint64 `json:"netout,omitempty"`
+ DiskRead uint64 `json:"diskread,omitempty"`
+ DiskWrite uint64 `json:"diskwrite,omitempty"`
+ Uptime uint64 `json:"uptime,omitempty"`
+ Template int `json:"template,omitempty"`
+ Tags string `json:"tags,omitempty"`
+}
+
+// GetClusterResources returns all resources (VMs, containers) across the cluster
+func (c *Client) GetClusterResources(ctx context.Context, resourceType string) ([]ClusterResource, error) {
+ path := "/cluster/resources"
+ if resourceType != "" {
+ path = fmt.Sprintf("%s?type=%s", path, resourceType)
+ }
+
+ resp, err := c.get(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var result struct {
+ Data []ClusterResource `json:"data"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ return result.Data, nil
+}
+
// VMStatus represents detailed VM status
type VMStatus struct {
Status string `json:"status"`
diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go
index a675f179a..bcc027501 100644
--- a/pkg/proxmox/cluster_client.go
+++ b/pkg/proxmox/cluster_client.go
@@ -591,6 +591,34 @@ func (cc *ClusterClient) GetVMAgentInfo(ctx context.Context, node string, vmid i
return result, err
}
+// GetClusterResources returns all resources (VMs, containers) across the cluster in a single call
+func (cc *ClusterClient) GetClusterResources(ctx context.Context, resourceType string) ([]ClusterResource, error) {
+ var result []ClusterResource
+ err := cc.executeWithFailover(ctx, func(client *Client) error {
+ resources, err := client.GetClusterResources(ctx, resourceType)
+ if err != nil {
+ return err
+ }
+ result = resources
+ return nil
+ })
+ return result, err
+}
+
+// GetContainerStatus returns the status of a specific container
+func (cc *ClusterClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*Container, error) {
+ var result *Container
+ err := cc.executeWithFailover(ctx, func(client *Client) error {
+ status, err := client.GetContainerStatus(ctx, node, vmid)
+ if err != nil {
+ return err
+ }
+ result = status
+ return nil
+ })
+ return result, err
+}
+
// GetClusterHealthInfo returns detailed health information about the cluster
func (cc *ClusterClient) GetClusterHealthInfo() models.ClusterHealth {
cc.mu.RLock()
diff --git a/pkg/tlsutil/fingerprint.go b/pkg/tlsutil/fingerprint.go
index 4c1adadc9..01e6177d8 100644
--- a/pkg/tlsutil/fingerprint.go
+++ b/pkg/tlsutil/fingerprint.go
@@ -41,6 +41,12 @@ func FingerprintVerifier(fingerprint string) *tls.Config {
func CreateHTTPClient(verifySSL bool, fingerprint string) *http.Client {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
+ // Performance optimizations for concurrent requests
+ MaxIdleConns: 100, // Increase from default 2
+ MaxIdleConnsPerHost: 20, // Increase from default 2
+ MaxConnsPerHost: 20, // Limit concurrent connections per host
+ IdleConnTimeout: 90 * time.Second,
+ DisableCompression: true, // Disable compression for lower latency
}
if !verifySSL && fingerprint == "" {
diff --git a/scripts/remove-password.sh b/scripts/remove-password.sh
deleted file mode 100755
index 6af673cf6..000000000
--- a/scripts/remove-password.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/bash
-# Script to remove authentication from Pulse systemd configuration
-# This needs to be run with sudo
-
-OVERRIDE_FILE="/etc/systemd/system/pulse-backend.service.d/override.conf"
-
-if [ ! -f "$OVERRIDE_FILE" ]; then
- echo "No override file found, authentication already removed"
- exit 0
-fi
-
-# Remove all authentication-related environment variables from the override file
-if grep -q "PULSE_AUTH_USER\|PULSE_AUTH_PASS\|PULSE_PASSWORD\|API_TOKEN" "$OVERRIDE_FILE"; then
- # Create a backup
- cp "$OVERRIDE_FILE" "$OVERRIDE_FILE.bak"
-
- # Remove the authentication lines but keep other settings
- grep -v "PULSE_AUTH_USER\|PULSE_AUTH_PASS\|PULSE_PASSWORD\|API_TOKEN" "$OVERRIDE_FILE" > "$OVERRIDE_FILE.tmp"
- mv "$OVERRIDE_FILE.tmp" "$OVERRIDE_FILE"
-
- # Reload systemd and restart the service
- systemctl daemon-reload
- systemctl restart pulse-backend
-
- echo "Authentication removed successfully"
-else
- echo "No authentication configuration found in override file"
-fi
\ No newline at end of file
diff --git a/test-60s.sh b/test-60s.sh
new file mode 100755
index 000000000..3aef4ca67
--- /dev/null
+++ b/test-60s.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+TOKEN="pulse-monitor@pam!test-token=a0c05119-0e04-4918-ac94-1fa604259bf1"
+URL="https://192.168.0.5:8006/api2/json/nodes"
+
+echo "Testing for 60 seconds..."
+last_cpu=""
+changes=()
+
+for i in {1..60}; do
+ response=$(curl -sk -H "Authorization: PVEAPIToken=$TOKEN" "$URL")
+ cpu=$(echo "$response" | jq -r '.data[] | select(.node=="delly") | .cpu')
+
+ if [ "$i" -eq 1 ]; then
+ echo "Second $i: CPU=$cpu (initial)"
+ elif [ "$cpu" != "$last_cpu" ]; then
+ echo "Second $i: CPU changed"
+ changes+=($i)
+ fi
+
+ last_cpu=$cpu
+ sleep 1
+done
+
+echo ""
+echo "Changes at seconds: ${changes[@]}"
+echo "Total changes: ${#changes[@]} in 60 seconds"
+if [ ${#changes[@]} -gt 0 ]; then
+ echo "Average interval: $((60 / ${#changes[@]})) seconds"
+fi
\ No newline at end of file
diff --git a/test-all-endpoints.sh b/test-all-endpoints.sh
new file mode 100755
index 000000000..c8132d401
--- /dev/null
+++ b/test-all-endpoints.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+TOKEN="pulse-monitor@pam!test-token=a0c05119-0e04-4918-ac94-1fa604259bf1"
+AUTH="Authorization: PVEAPIToken=$TOKEN"
+BASE="https://192.168.0.5:8006/api2/json"
+
+echo "Testing different Proxmox endpoints for CPU data..."
+echo "================================================"
+
+# Test 1: /nodes endpoint
+echo -e "\n1. Testing /nodes endpoint (10 samples):"
+last=""
+for i in {1..10}; do
+ cpu=$(curl -sk -H "$AUTH" "$BASE/nodes" | jq -r '.data[] | select(.node=="delly") | .cpu')
+ if [ "$cpu" != "$last" ]; then
+ echo " Sample $i: CPU=$cpu (changed)"
+ else
+ echo " Sample $i: CPU=$cpu"
+ fi
+ last=$cpu
+ sleep 1
+done
+
+# Test 2: /nodes/delly/status endpoint
+echo -e "\n2. Testing /nodes/delly/status endpoint (10 samples):"
+last=""
+for i in {1..10}; do
+ cpu=$(curl -sk -H "$AUTH" "$BASE/nodes/delly/status" | jq -r '.data.cpu // 0')
+ if [ "$cpu" != "$last" ]; then
+ echo " Sample $i: CPU=$cpu (changed)"
+ else
+ echo " Sample $i: CPU=$cpu"
+ fi
+ last=$cpu
+ sleep 1
+done
+
+# Test 3: /cluster/resources endpoint
+echo -e "\n3. Testing /cluster/resources endpoint (10 samples):"
+last=""
+for i in {1..10}; do
+ cpu=$(curl -sk -H "$AUTH" "$BASE/cluster/resources?type=node" | jq -r '.data[] | select(.node=="delly") | .cpu // 0')
+ if [ "$cpu" != "$last" ]; then
+ echo " Sample $i: CPU=$cpu (changed)"
+ else
+ echo " Sample $i: CPU=$cpu"
+ fi
+ last=$cpu
+ sleep 1
+done
\ No newline at end of file
diff --git a/test-api.sh b/test-api.sh
new file mode 100755
index 000000000..7850c2773
--- /dev/null
+++ b/test-api.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+for i in {1..10}; do
+ timestamp=$(date +"%H:%M:%S")
+ cpu=$(curl -s -H "X-API-Token: 0999c3bdf6d98647da81c00643ea5c4fe4560aaefed9519e" http://localhost:7655/api/state | jq -r '.nodes[] | select(.name=="delly") | .cpu')
+ echo "$timestamp: $cpu"
+ sleep 2
+done
\ No newline at end of file
diff --git a/test-nodes-endpoint.py b/test-nodes-endpoint.py
new file mode 100644
index 000000000..e8041ebc2
--- /dev/null
+++ b/test-nodes-endpoint.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""
+Test /nodes endpoint to see update frequency
+"""
+import time
+import json
+import subprocess
+from datetime import datetime
+
+def get_nodes_data():
+ """Get all nodes data"""
+ try:
+ result = subprocess.run(
+ ['ssh', 'root@delly', 'pvesh', 'get', '/nodes', '--output-format', 'json'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ return json.loads(result.stdout)
+ except Exception as e:
+ print(f"Error: {e}")
+ return None
+
+def main():
+ print("Testing /nodes endpoint - tracking delly specifically")
+ print("Polling every 1 second for 30 seconds")
+ print("-" * 80)
+
+ last_cpu = None
+ last_mem = None
+ cpu_changes = []
+ mem_changes = []
+
+ for i in range(30):
+ current_time = datetime.now().strftime('%H:%M:%S')
+ nodes = get_nodes_data()
+
+ if nodes is None:
+ print(f"{current_time} - Failed to get data")
+ time.sleep(1)
+ continue
+
+ # Find delly
+ delly = None
+ for node in nodes:
+ if node.get('node') == 'delly':
+ delly = node
+ break
+
+ if delly is None:
+ print(f"{current_time} - Delly not found")
+ time.sleep(1)
+ continue
+
+ cpu = delly.get('cpu', 0)
+ mem = delly.get('mem', 0)
+
+ if last_cpu is not None:
+ if cpu != last_cpu:
+ print(f"{current_time} - CPU changed: {last_cpu:.10f} -> {cpu:.10f}")
+ cpu_changes.append(i)
+
+ if mem != last_mem:
+ delta_mb = (mem - last_mem) / (1024*1024)
+ print(f"{current_time} - Mem changed: {delta_mb:+.1f} MB")
+ mem_changes.append(i)
+ else:
+ print(f"{current_time} - Initial: CPU={cpu:.10f}, Mem={mem/(1024*1024*1024):.2f} GB")
+
+ last_cpu = cpu
+ last_mem = mem
+ time.sleep(1)
+
+ print(f"\nCPU changes at seconds: {cpu_changes}")
+ print(f"Memory changes at seconds: {mem_changes}")
+
+ if len(cpu_changes) > 1:
+ intervals = [cpu_changes[i+1] - cpu_changes[i] for i in range(len(cpu_changes)-1)]
+ print(f"CPU change intervals: {intervals}")
+ print(f"Average CPU update interval: {sum(intervals)/len(intervals):.1f} seconds")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/test-proxmox-fast.py b/test-proxmox-fast.py
new file mode 100644
index 000000000..013c7d335
--- /dev/null
+++ b/test-proxmox-fast.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""
+Test with specific node endpoint instead of /nodes
+"""
+import time
+import json
+import subprocess
+from datetime import datetime
+
+def get_node_stats_specific():
+ """Get delly stats from specific node endpoint"""
+ try:
+ # Use the specific node endpoint
+ result = subprocess.run(
+ ['ssh', 'root@delly', 'pvesh', 'get', '/nodes/delly/status', '--output-format', 'json'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ node = json.loads(result.stdout)
+ return {
+ 'cpu': node.get('cpu', 0),
+ 'wait': node.get('wait', 0),
+ 'load': node.get('loadavg', [0])[0] if 'loadavg' in node else 0,
+ 'mem_used': node.get('memory', {}).get('used', 0),
+ 'mem_total': node.get('memory', {}).get('total', 0),
+ 'uptime': node.get('uptime', 0)
+ }
+ except Exception as e:
+ print(f"Error: {e}")
+ return None
+
+def main():
+ print("Testing /nodes/delly/status endpoint specifically")
+ print("Polling every 1 second for 30 seconds")
+ print("-" * 80)
+
+ last_stats = None
+ changes_at = []
+
+ for i in range(30):
+ current_time = datetime.now().strftime('%H:%M:%S')
+ stats = get_node_stats_specific()
+
+ if stats is None:
+ print(f"{current_time} - Failed to get stats")
+ time.sleep(1)
+ continue
+
+ if last_stats is not None:
+ # Check if CPU changed
+ if stats['cpu'] != last_stats['cpu']:
+ delta = stats['cpu'] - last_stats['cpu']
+ print(f"{current_time} - CPU changed: {last_stats['cpu']:.10f} -> {stats['cpu']:.10f} (delta: {delta:+.10f})")
+ changes_at.append(i)
+
+ # Check memory
+ if stats['mem_used'] != last_stats['mem_used']:
+ delta_mb = (stats['mem_used'] - last_stats['mem_used']) / (1024*1024)
+ print(f"{current_time} - Memory changed: {delta_mb:+.1f} MB")
+ else:
+ print(f"{current_time} - Initial CPU: {stats['cpu']:.10f}, Mem: {stats['mem_used']/(1024*1024*1024):.2f} GB")
+
+ last_stats = stats
+ time.sleep(1)
+
+ if len(changes_at) > 1:
+ intervals = [changes_at[i+1] - changes_at[i] for i in range(len(changes_at)-1)]
+ avg_interval = sum(intervals) / len(intervals) if intervals else 0
+ print(f"\nChanges detected at seconds: {changes_at}")
+ print(f"Intervals between changes: {intervals}")
+ print(f"Average interval: {avg_interval:.1f} seconds")
+ else:
+ print(f"\nOnly {len(changes_at)} changes detected in 30 seconds")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/test-proxmox-polling.py b/test-proxmox-polling.py
new file mode 100755
index 000000000..e00d07b3a
--- /dev/null
+++ b/test-proxmox-polling.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""
+Test script to monitor how frequently Proxmox API values actually change
+"""
+import time
+import json
+import subprocess
+from datetime import datetime
+
+def get_node_stats():
+ """Get node stats directly from Proxmox API using pvesh"""
+ try:
+ result = subprocess.run(
+ ['ssh', 'root@delly', 'pvesh', 'get', '/nodes', '--output-format', 'json'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ data = json.loads(result.stdout)
+ # Find delly specifically
+ node = None
+ for n in data:
+ if n.get('node') == 'delly':
+ node = n
+ break
+ if node is None:
+ return None
+ return {
+ 'cpu': node.get('cpu', 0),
+ 'mem': node.get('mem', 0),
+ 'maxmem': node.get('maxmem', 0),
+ 'disk': node.get('disk', 0),
+ 'maxdisk': node.get('maxdisk', 0),
+ 'uptime': node.get('uptime', 0)
+ }
+ except Exception as e:
+ print(f"Error: {e}")
+ return None
+
+def main():
+ print("Monitoring Proxmox API for value changes...")
+ print("Polling every 0.5 seconds to catch any changes")
+ print("-" * 80)
+
+ last_stats = None
+ last_change_time = None
+ poll_count = 0
+ change_count = 0
+
+ # Track when each metric last changed
+ last_changes = {}
+
+ # Run for 60 seconds
+ start_time = time.time()
+ duration = 60
+
+ while time.time() - start_time < duration:
+ poll_count += 1
+ current_time = datetime.now().strftime('%H:%M:%S.%f')[:-3]
+ stats = get_node_stats()
+
+ if stats is None:
+ print(f"{current_time} - Failed to get stats")
+ time.sleep(0.5)
+ continue
+
+ if last_stats is None:
+ # First poll
+ print(f"{current_time} - Initial values:")
+ print(f" CPU: {stats['cpu']:.10f}")
+ print(f" Memory: {stats['mem']} / {stats['maxmem']}")
+ print(f" Disk: {stats['disk']} / {stats['maxdisk']}")
+ print(f" Uptime: {stats['uptime']}")
+ for key in stats:
+ last_changes[key] = current_time
+ else:
+ # Check what changed
+ changes = []
+ for key in stats:
+ if stats[key] != last_stats[key]:
+ time_since_last = None
+ if key in last_changes:
+ # Calculate seconds since last change
+ try:
+ prev_time = datetime.strptime(last_changes[key], '%H:%M:%S.%f')
+ curr_time = datetime.strptime(current_time, '%H:%M:%S.%f')
+ delta = (curr_time - prev_time).total_seconds()
+ time_since_last = f"{delta:.1f}s"
+ except:
+ pass
+
+ if key == 'cpu':
+ changes.append(f"CPU: {last_stats[key]:.10f} -> {stats[key]:.10f} (after {time_since_last})")
+ elif key in ['mem', 'disk']:
+ changes.append(f"{key.upper()}: {last_stats[key]} -> {stats[key]} (after {time_since_last})")
+ elif key == 'uptime':
+ changes.append(f"Uptime: +{stats[key] - last_stats[key]}s (after {time_since_last})")
+
+ last_changes[key] = current_time
+
+ if changes:
+ change_count += 1
+ print(f"{current_time} - CHANGES DETECTED (poll #{poll_count}):")
+ for change in changes:
+ print(f" {change}")
+ last_change_time = current_time
+
+ last_stats = stats
+ time.sleep(0.5) # Poll every 500ms to catch any changes
+
+ # Summary
+ print("\n" + "=" * 80)
+ print("SUMMARY:")
+ print(f"Total polls: {poll_count}")
+ print(f"Total changes detected: {change_count}")
+ print(f"Average time between changes: {duration/change_count if change_count > 0 else 0:.1f} seconds")
+ print("\nTime between changes for each metric:")
+
+ # This is approximate based on change count
+ if change_count > 0:
+ avg_interval = poll_count / change_count * 0.5
+ print(f"Estimated update interval: ~{avg_interval:.1f} seconds")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/test-raw-curl.sh b/test-raw-curl.sh
new file mode 100755
index 000000000..ffaf28271
--- /dev/null
+++ b/test-raw-curl.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+# Simple curl test to check Proxmox update frequency
+TOKEN="pulse-monitor@pam!test-token=a0c05119-0e04-4918-ac94-1fa604259bf1"
+URL="https://192.168.0.5:8006/api2/json/nodes"
+
+echo "Testing Proxmox API update frequency with raw curl"
+echo "Polling every 1 second for 30 seconds"
+echo "================================================"
+
+last_cpu=""
+last_mem=""
+count=0
+
+for i in {1..30}; do
+ # Get current time
+ timestamp=$(date +"%H:%M:%S")
+
+ # Make API call
+ response=$(curl -sk -H "Authorization: PVEAPIToken=$TOKEN" "$URL")
+
+ # Extract CPU and memory for delly node
+ cpu=$(echo "$response" | jq -r '.data[] | select(.node=="delly") | .cpu')
+ mem=$(echo "$response" | jq -r '.data[] | select(.node=="delly") | .mem')
+
+ # Check if values changed
+ if [ "$i" -eq 1 ]; then
+ echo "$timestamp - Initial: CPU=$cpu, Mem=$((mem / 1024 / 1024 / 1024)) GB"
+ else
+ if [ "$cpu" != "$last_cpu" ]; then
+ echo "$timestamp - CPU CHANGED: $last_cpu -> $cpu"
+ ((count++))
+ fi
+ if [ "$mem" != "$last_mem" ]; then
+ mem_diff=$(( (mem - last_mem) / 1024 / 1024 ))
+ if [ "$mem_diff" -ne 0 ]; then
+ echo "$timestamp - MEM CHANGED: ${mem_diff:+}${mem_diff} MB"
+ fi
+ fi
+ fi
+
+ last_cpu=$cpu
+ last_mem=$mem
+
+ sleep 1
+done
+
+echo ""
+echo "Total CPU changes detected: $count in 30 seconds"
\ No newline at end of file