fix: resolve WebSocket metric updates and improve polling efficiency

- Fix alternating zero I/O metrics by implementing rate caching for stale data from Proxmox
- Hardcode polling interval to 10 seconds (matching Proxmox cluster/resources update cycle)
- Remove polling interval settings from UI (no longer user-configurable)
- Implement efficient VM/container polling using single cluster/resources API call
- Remove 'Remove Password' feature (auth is now mandatory)
- Fix CSRF validation for Basic Auth (exempt from CSRF checks)
- Fix Generate API Token modal and authentication
- Remove redundant 'Active' status from Authentication section
- Remove Connection Timeout setting from frontend (backend-only)
- Clean up frontend console logging (reduce verbosity)
- Remove PBS polling interval setting (fixed at 10s)
- Add frontend rebuild detection to backend-watch script
- Improve first-run setup flow and error handling
This commit is contained in:
Pulse Monitor
2025-08-16 12:12:10 +00:00
parent f524166f9d
commit b316510a2b
45 changed files with 2847 additions and 947 deletions
+68
View File
@@ -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.
+11 -1
View File
@@ -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**
+71 -4
View File
@@ -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")
}
+117 -311
View File
@@ -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://<server>: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
```
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
+8 -15
View File
@@ -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<T = unknown> {
success?: boolean;
@@ -40,13 +28,18 @@ export class SettingsAPI {
}) as Promise<ApiResponse>;
}
// System settings update (preferred)
static async updateSystemSettings(settings: SystemSettingsUpdate): Promise<ApiResponse> {
// System settings update (preferred) - uses SystemConfig type from config.ts
static async updateSystemSettings(settings: Partial<SystemConfig>): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/config/system`, {
method: 'PUT',
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
// Get system settings - returns SystemConfig
static async getSystemSettings(): Promise<SystemConfig> {
return apiFetchJSON(`${this.baseUrl}/config/system`) as Promise<SystemConfig>;
}
static async validateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/settings/validate`, {
@@ -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 (
<Show when={!props.disabled} fallback={<span class="text-sm text-gray-400">-</span>}>
<span class={`text-sm font-mono ${colorClass()}`}>
{formatted()}
</span>
<div class={`text-sm font-mono ${colorClass()} overflow-visible relative`} style="min-height: 24px;">
<AnimatedMetric
value={currentValue()}
formatter={(v) => formatSpeed(v, 0)}
/>
</div>
</Show>
);
}
@@ -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<NodeCardProps> = (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 = () => {
@@ -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 (
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div class="w-full max-w-2xl">
{/* Logo/Header */}
<div class="text-center mb-8">
<div class="flex items-center justify-center gap-2 mb-4">
<svg
width="48"
height="48"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class="pulse-logo"
>
<title>Pulse Logo</title>
<circle class="pulse-bg fill-blue-600 dark:fill-blue-500" cx="128" cy="128" r="122"/>
<circle class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]" cx="128" cy="128" r="84"/>
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26"/>
</svg>
<span class="text-4xl font-bold text-gray-800 dark:text-gray-100">Pulse</span>
</div>
<p class="text-gray-600 dark:text-gray-400">
Let's set up your monitoring dashboard
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
<Show when={!showCredentials()}>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-6">
Initial Security Setup
</h2>
<div class="space-y-6">
{/* Username */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Admin Username
</label>
<input
type="text"
value={username()}
onInput={(e) => 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"
/>
</div>
{/* Password Setup */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Admin Password
</label>
<div class="flex gap-2 mb-3">
<button
onClick={() => setUseCustomPassword(false)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
!useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Generate Secure Password
</button>
<button
onClick={() => setUseCustomPassword(true)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Set Custom Password
</button>
</div>
<Show when={useCustomPassword()}>
<div class="space-y-3">
<input
type="password"
value={password()}
onInput={(e) => 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)"
/>
<input
type="password"
value={confirmPassword()}
onInput={(e) => 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"
/>
</div>
</Show>
<Show when={!useCustomPassword()}>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
A secure 16-character password will be generated for you.
Make sure to save it when shown!
</p>
</div>
</Show>
</div>
{/* Additional Settings */}
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Initial Configuration
</h3>
{/* Enable Notifications */}
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-700 dark:text-gray-300">
Enable Desktop Notifications
</label>
<p class="text-xs text-gray-500 dark:text-gray-500">
Get notified about alerts and important events
</p>
</div>
<button
type="button"
onClick={() => setEnableNotifications(!enableNotifications())}
class={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
enableNotifications() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
class={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
enableNotifications() ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Dark Mode */}
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-700 dark:text-gray-300">
Dark Mode
</label>
<p class="text-xs text-gray-500 dark:text-gray-500">
Use dark theme for the dashboard
</p>
</div>
<button
type="button"
onClick={() => {
setDarkMode(!darkMode());
if (!darkMode()) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
}}
class={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
darkMode() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
class={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
darkMode() ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Info Box */}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-2">
<h3 class="font-medium text-gray-800 dark:text-gray-200">
What happens next:
</h3>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Your admin account will be created</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>An API token will be generated for automation</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>All API endpoints will be protected</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>You'll need to login to access the dashboard</span>
</li>
</ul>
</div>
{/* Setup Button */}
<button
onClick={handleSetup}
disabled={isSettingUp()}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
>
{isSettingUp() ? 'Setting up...' : 'Complete Setup'}
</button>
</div>
</div>
</Show>
<Show when={showCredentials()}>
<div class="p-8">
<div class="text-center mb-6">
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Setup Complete!
</h2>
<p class="text-gray-600 dark:text-gray-400">
Save your credentials now - they won't be shown again
</p>
</div>
<div class="space-y-4">
{/* Username */}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Username
</label>
<div class="font-mono text-lg text-gray-900 dark:text-gray-100">
{savedUsername()}
</div>
</div>
{/* Password */}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Password
</label>
<div class="flex items-center justify-between">
<code class="font-mono text-lg text-gray-900 dark:text-gray-100 break-all">
{savedPassword()}
</code>
<button
onClick={() => handleCopy('password')}
class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Copy password"
>
{copied() === 'password' ? (
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
)}
</button>
</div>
</div>
{/* API Token */}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
API Token (for automation)
</label>
<div class="flex items-center justify-between">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">
{savedToken()}
</code>
<button
onClick={() => handleCopy('token')}
class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Copy token"
>
{copied() === 'token' ? (
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
)}
</button>
</div>
</div>
{/* Warning */}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">
Important
</p>
<p class="text-xs text-amber-700 dark:text-amber-300">
These credentials will never be shown again. Save them in a password manager now!
</p>
</div>
{/* Action Buttons */}
<div class="flex gap-3">
<button
onClick={downloadCredentials}
class="flex-1 py-3 px-4 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Credentials
</button>
<button
onClick={() => window.location.reload()}
class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Continue to Login
</button>
</div>
</div>
</div>
</Show>
</div>
</div>
</div>
);
};
@@ -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<APIOnlySetupProps> = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [token, setToken] = createSignal<string | null>(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 (
<div class="space-y-4">
<Show when={!showToken()}>
<div class="space-y-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
Generate an API token for programmatic access. Use it for:
</p>
<ul class="list-disc list-inside text-xs text-gray-600 dark:text-gray-400 space-y-1">
<li>Automation scripts and CI/CD pipelines</li>
<li>Monitoring integrations</li>
<li>Third-party applications</li>
</ul>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p class="text-xs text-amber-700 dark:text-amber-300">
<strong>Note:</strong> Without password authentication enabled,
the UI will remain publicly accessible.
</p>
</div>
<button
onClick={generateToken}
disabled={isGenerating()}
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating() ? 'Generating...' : 'Generate API Token'}
</button>
</div>
</Show>
<Show when={showToken() && token()}>
<div class="space-y-4">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
API Token Generated!
</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Save this token now - it will never be shown again!
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Your API Token</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{token()}
</code>
<button
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<button
onClick={() => {
setShowToken(false);
setToken(null);
if (props.onTokenGenerated) {
props.onTokenGenerated();
}
}}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Done - I've Saved My Token
</button>
</div>
</Show>
</div>
);
};
@@ -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<GenerateAPITokenProps> = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [newToken, setNewToken] = createSignal<string | null>(null);
const [showToken, setShowToken] = createSignal(false);
const [copied, setCopied] = createSignal(false);
const [deploymentType, setDeploymentType] = createSignal<string>('');
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 (
<div class="space-y-4">
<Show when={!showToken()}>
@@ -71,25 +70,25 @@ export const GenerateAPIToken: Component = () => {
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
API Token Active
</h4>
<Show when={currentHint() && currentHint().length > 0}>
<div class="mb-3 px-3 py-2 bg-gray-800 dark:bg-gray-950 rounded">
<code class="text-xs text-gray-300 font-mono">
Current token: {currentHint()}
</code>
</div>
</Show>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
An API token is configured for this instance. Use it with the X-API-Token header for automation.
</p>
<button
onClick={generateNewToken}
onClick={() => setShowConfirm(true)}
disabled={isGenerating()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating() ? 'Generating...' : 'Generate New Token'}
</button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<p class="font-medium mb-1">Using the API Token:</p>
<code class="block bg-gray-900 dark:bg-gray-950 text-gray-100 p-2 rounded text-xs">
curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/...
</code>
</div>
</Show>
<Show when={showToken() && newToken()}>
@@ -118,15 +117,15 @@ export const GenerateAPIToken: Component = () => {
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs text-amber-700 dark:text-amber-300">
<p class="font-semibold">Restart Required</p>
<p class="mt-1">{getRestartInstructions()}</p>
<p class="mt-1 text-amber-600 dark:text-amber-400">The old token has been invalidated and will no longer work.</p>
<div class="text-xs text-blue-700 dark:text-blue-300">
<p class="font-semibold">Token Active Immediately!</p>
<p class="mt-1">Your new API token is active and ready to use.</p>
<p class="mt-1 text-blue-600 dark:text-blue-400">The old token (if any) has been invalidated.</p>
</div>
</div>
</div>
@@ -142,6 +141,34 @@ export const GenerateAPIToken: Component = () => {
</button>
</div>
</Show>
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Generate New API Token?
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
This will generate a new API token and <span class="font-semibold text-red-600 dark:text-red-400">immediately invalidate the current token</span>.
Any scripts or integrations using the old token will stop working.
</p>
<div class="flex gap-3 justify-end">
<button
onClick={() => setShowConfirm(false)}
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={generateNewToken}
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Generate New Token
</button>
</div>
</div>
</div>
</Show>
</div>
);
};
@@ -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<NodeConfig>) => void;
showBackToDiscovery?: boolean;
onBackToDiscovery?: () => void;
securityStatus?: any;
securityStatus?: Partial<SecurityStatus>;
}
export const NodeModal: Component<NodeModalProps> = (props) => {
@@ -485,7 +486,6 @@ export const NodeModal: Component<NodeModalProps> = (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' },
@@ -35,7 +35,8 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (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<QuickSecuritySetupProps> = (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.
</p>
</div>
<div class="flex justify-end">
<button
onClick={() => {
setShowCredentials(false);
// Now notify parent that configuration is complete
if (props.onConfigured) {
props.onConfigured();
}
// Reload the page to trigger login screen
window.location.reload();
}}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Done - I've Saved My Credentials
</button>
</div>
</div>
</Show>
</div>
@@ -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<RemovePasswordModalProps> = (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<string, string> = {
'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 (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
Remove Password Authentication
</h2>
<div class="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-700 dark:text-amber-300">
<strong>Warning:</strong> This will disable password authentication.
Pulse will be accessible without any login. Only do this if you're on a trusted network.
</p>
</div>
<form onSubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<input
type="password"
id="current-password"
value={currentPassword()}
onInput={(e) => 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
/>
</div>
<Show when={error()}>
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
</div>
</Show>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button
type="button"
onClick={handleClose}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={loading()}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading()}
>
{loading() ? 'Removing...' : 'Remove Password'}
</button>
</div>
</form>
</div>
</div>
</Portal>
</Show>
);
};
@@ -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<VersionInfo | null>(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 = () => {
</div>
<div class="space-y-4">
{/* Performance Settings */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
Performance Settings
</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Polling Interval</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
How often to fetch data from servers
</p>
</div>
<select
value={pollingInterval()}
onChange={(e) => {
setPollingInterval(parseInt(e.currentTarget.value));
setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="3">3 seconds</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Connection Timeout</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Max wait time for node responses
</p>
</div>
<select
value={connectionTimeout()}
onChange={(e) => {
setConnectionTimeout(parseInt(e.currentTarget.value));
setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="20">20 seconds</option>
<option value="30">30 seconds</option>
</select>
</div>
</div>
</div>
{/* Network Settings */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
@@ -1437,29 +1373,21 @@ const Settings: Component = () => {
{/* Security Tab */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
{/* Authentication Status */}
{/* Authentication */}
<Show when={securityStatus()?.hasAuthentication}>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-green-100 dark:bg-green-900/50 rounded-lg">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Authentication</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Password protection enabled</p>
</div>
</div>
<span class="px-3 py-1.5 bg-green-500 text-white text-xs font-medium rounded-full flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<div class="bg-gradient-to-r from-gray-50 to-gray-50 dark:from-gray-900/20 dark:to-gray-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-gray-100 dark:bg-gray-900/50 rounded-lg">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Active
</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Authentication</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Manage your login credentials</p>
</div>
</div>
</div>
@@ -1481,20 +1409,6 @@ const Settings: Component = () => {
</div>
</button>
<button
onClick={() => setShowRemovePasswordModal(true)}
class="flex items-center gap-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all group"
>
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg group-hover:bg-red-200 dark:group-hover:bg-red-900/50 transition-colors">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="text-left">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Remove Password</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Disable authentication</div>
</div>
</button>
</div>
</div>
</div>
@@ -1519,26 +1433,70 @@ const Settings: Component = () => {
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
After restarting, you'll need to log in with your saved credentials.
</p>
<div class="mt-4 bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">
How to restart Pulse:
</p>
<Show when={versionInfo()?.deploymentType === 'proxmoxve'}>
<div class="space-y-2">
<p class="text-xs text-gray-700 dark:text-gray-300">
Type <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">update</code> in your ProxmoxVE console
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 italic">
Or restart manually with: <code class="text-xs">systemctl restart pulse</code>
</p>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'docker'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart your Docker container:</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
docker restart pulse
</code>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'systemd' || versionInfo()?.deploymentType === 'manual'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart the service:</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse
</code>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'development'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart the development server:</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse-backend
</code>
</div>
</Show>
<Show when={!versionInfo()?.deploymentType}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart Pulse using your deployment method</p>
</div>
</Show>
</div>
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-xs text-green-700 dark:text-green-300">
💡 <strong>Tip:</strong> Make sure you've saved your credentials before restarting!
</p>
</div>
</div>
</div>
</div>
</Show>
{/* Show setup when no auth and not pending */}
<Show when={!securityStatus()?.hasAuthentication && !securityStatus()?.configuredButPendingRestart}>
<QuickSecuritySetup onConfigured={async () => {
// 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);
}
}} />
</Show>
{/* 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 */}
<Show when={securityStatus()?.hasAuthentication && securityStatus()?.apiTokenConfigured}>
@@ -1560,7 +1518,7 @@ const Settings: Component = () => {
{/* Content */}
<div class="p-6">
<GenerateAPIToken />
<GenerateAPIToken currentTokenHint={securityStatus()?.apiTokenHint} />
</div>
</div>
</Show>
@@ -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)}
/>
<RemovePasswordModal
isOpen={showRemovePasswordModal()}
onClose={() => setShowRemovePasswordModal(false)}
/>
</>
);
};
@@ -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<AnimatedMetricProps> = (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 (
<div class="metric-container" style="position: relative; display: inline-block; overflow: visible;">
{showGhost() && (
<span
class={`metric-ghost metric-ghost-${animClass()}`}
style="position: absolute; top: 0; left: 0; z-index: 1;"
>
{format(oldValue())}
</span>
)}
<span
class={`metric-value ${showGhost() ? `metric-entering-${animClass()}` : ''}`}
style="position: relative; z-index: 2; display: inline-block;"
>
{format(displayValue())}
</span>
</div>
);
};
+1
View File
@@ -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';
+4 -5
View File
@@ -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);
+193
View File
@@ -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);
}
}
+142
View File
@@ -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<AuthConfig>; // 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,
}
};
+5 -2
View File
@@ -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) => {
+1
View File
@@ -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
+2
View File
@@ -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=
+3 -2
View File
@@ -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..."
+66 -2
View File
@@ -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
+37 -16
View File
@@ -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
}
}
+12 -91
View File
@@ -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) {
+5
View File
@@ -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 {
+79 -36
View File
@@ -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")
+84 -61
View File
@@ -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)
+7 -5
View File
@@ -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
}
+218
View File
@@ -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")
}
}
+168 -24
View File
@@ -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,
+46 -6
View File
@@ -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)
}
+28
View File
@@ -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)
}
+90 -2
View File
@@ -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"`
+28
View File
@@ -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()
+6
View File
@@ -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 == "" {
-28
View File
@@ -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
Executable
+30
View File
@@ -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
+50
View File
@@ -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
Executable
+7
View File
@@ -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
+84
View File
@@ -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()
+78
View File
@@ -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()
+126
View File
@@ -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()
+49
View File
@@ -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"