mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
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:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
Executable
+50
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+126
@@ -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()
|
||||
Executable
+49
@@ -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"
|
||||
Reference in New Issue
Block a user