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 +
+

+ Let's set up your monitoring dashboard +

+
+ +
+ +
+

+ Initial Security Setup +

+ +
+ {/* 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 */} +
+ + +
+ + +
+ + +
+ 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 */} +
+
+ +

+ Get notified about alerts and important events +

+
+ +
+ + {/* Dark Mode */} +
+
+ +

+ Use dark theme for the dashboard +

+
+ +
+
+ + {/* 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 */} + +
+
+
+ + +
+
+
+ + + +
+

+ Setup Complete! +

+

+ Save your credentials now - they won't be shown again +

+
+ +
+ {/* Username */} +
+ +
+ {savedUsername()} +
+
+ + {/* Password */} +
+ +
+ + {savedPassword()} + + +
+
+ + {/* API Token */} +
+ +
+ + {savedToken()} + + +
+
+ + {/* Warning */} +
+

+ ⚠️ Important +

+

+ These credentials will never be shown again. Save them in a password manager now! +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+
+
+
+
+ ); +}; \ 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. +

+
+ + +
+
+ + +
+
+

+ ✅ API Token Generated! +

+

+ Save this token now - it will never be shown again! +

+
+ +
+ +
+ + {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.

- -
-

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. +

+
+ + +
+
+
+
); }; \ 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.

+ +
+ +
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. -

-
- -
-
-
- - setCurrentPassword(e.currentTarget.value)} - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-200" - placeholder="Enter current password to confirm" - required - /> -
- - -
-

{error()}

-
-
-
- -
- - -
-
-
-
-
-
- ); -}; \ 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 -

- -
-
-
- -

- How often to fetch data from servers -

-
- -
- -
-
- -

- Max wait time for node responses -

-
- -
-
-
{/* Network Settings */}
@@ -1437,29 +1373,21 @@ const Settings: Component = () => { {/* Security Tab */}
- {/* Authentication Status */} + {/* Authentication */}
{/* Header */} -
-
-
-
- - - -
-
-

Authentication

-

Password protection enabled

-
-
- - - +
+
+
+ + - Active - +
+
+

Authentication

+

Manage your login credentials

+
@@ -1481,20 +1409,6 @@ const Settings: Component = () => {
-
@@ -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