diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 3fc0e68..32cde6b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -23,6 +23,19 @@ Pulse protects the initial Quick Security Setup screen with a one-time bootstrap | Docker container | `/data/.bootstrap_token` inside the container or the mounted host volume | | Helm / Kubernetes | The persistent volume mounted at `/data` | +**For Proxmox Quick Install (LXC):** +The installer creates an LXC container, so the token is inside the container, not on the PVE host. Use one of these commands from your Proxmox host: +```bash +# Enter the container interactively +pct enter +cat /etc/pulse/.bootstrap_token + +# Or retrieve token directly +pct exec -- cat /etc/pulse/.bootstrap_token +``` +The installer displays the container ID when installation completes. + +**For other deployments:** 1. SSH to the host (or `docker exec` into the container). 2. Display the token: `cat /etc/pulse/.bootstrap_token` (adjust the path per the table). 3. When the UI prompts for setup, paste the token into the dialog or send it as the `X-Setup-Token` header for API calls. diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index 5903b38..37bafef 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -23,52 +23,76 @@ Pulse can display real-time CPU and NVMe temperatures directly in your dashboard ## Quick Start for Docker Deployments -**Running Pulse in Docker?** Follow these steps to enable temperature monitoring: +**Running Pulse in Docker?** Temperature monitoring requires installing a small service on your Proxmox host that reads hardware sensors. The Pulse container connects to this service through a shared socket. + +**Why this is needed:** Docker containers cannot directly access hardware sensors. The proxy runs on your Proxmox host where it has access to sensor data, then shares that data with the Pulse container through a secure connection. + +Follow these steps to set it up: ### 1. Install the proxy on your Proxmox host -SSH to your **Proxmox host** (not the Docker container) and run: +SSH to your **Proxmox host** (not the Docker container) and run as root: ```bash -curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \ - bash -s -- --standalone --pulse-server http://YOUR_PULSE_IP:7655 +sudo curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \ + sudo bash -s -- --standalone --pulse-server http://192.168.1.100:7655 ``` -Replace `YOUR_PULSE_IP` with your Pulse server's IP address. +Replace `192.168.1.100:7655` with your Pulse server's actual IP address and port. + +The script will install and start the `pulse-sensor-proxy` service. You should see output confirming the installation succeeded. ### 2. Add bind mount to docker-compose.yml -Add this volume to your Pulse container configuration: +Edit your `docker-compose.yml` file and add the highlighted line to your Pulse service volumes: ```yaml +services: + pulse: + image: rcourtman/pulse:latest + ports: + - "7655:7655" + volumes: + - pulse-data:/data + - /run/pulse-sensor-proxy:/run/pulse-sensor-proxy:rw # Add this line + volumes: - - pulse-data:/data - - /run/pulse-sensor-proxy:/run/pulse-sensor-proxy:rw # Add this line + pulse-data: ``` +This connects the proxy socket from your host into the container so Pulse can communicate with it. + ### 3. Restart Pulse container ```bash -docker-compose down && docker-compose up -d +docker compose down +docker compose up -d ``` -### 4. Verify +Note: If you're using older Docker Compose v1, use `docker-compose` (with hyphen) instead. -Check Pulse UI for temperature data, or verify the setup: +### 4. Verify the setup +**Check proxy is running on your Proxmox host:** ```bash -# Verify proxy is running on host -systemctl status pulse-sensor-proxy - -# Verify socket is accessible in container -docker exec pulse ls -l /run/pulse-sensor-proxy/pulse-sensor-proxy.sock - -# Check Pulse logs -docker logs pulse | grep -i "temperature.*proxy" +sudo systemctl status pulse-sensor-proxy ``` +You should see `active (running)` in green. + +**Check Pulse detected the proxy:** +```bash +docker logs pulse 2>&1 | grep -i "temperature.*proxy" +``` + +Replace `pulse` with your container name if different (check with `docker ps`). + You should see: `Temperature proxy detected - using secure host-side bridge` +**Check temperatures appear in the UI:** + +Open Pulse in your browser and check the node dashboard. CPU and drive temperatures should now be visible. If you still see blank temperature fields, proceed to troubleshooting below. + **Having issues?** See [Troubleshooting](#troubleshooting) below. --- diff --git a/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx index 300ea0b..621b773 100644 --- a/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx +++ b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx @@ -148,22 +148,50 @@ export const PveNodesTable: Component = (props) => { 0}> -
+
- {(endpoint) => ( - - {endpoint.NodeName} - - {endpoint.IP} - - - )} + {(endpoint) => { + const pulseStatus = endpoint.PulseReachable === null || endpoint.PulseReachable === undefined + ? 'unknown' + : endpoint.PulseReachable + ? 'reachable' + : 'unreachable'; + + const statusColor = endpoint.Online && pulseStatus === 'reachable' + ? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300' + : pulseStatus === 'unreachable' + ? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300' + : endpoint.Online + ? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300' + : 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400'; + + return ( +
+
+ {endpoint.NodeName} + {endpoint.IP} +
+
+
+ Proxmox: + {endpoint.Online ? 'Online' : 'Offline'} +
+
+ Pulse: + + {pulseStatus === 'reachable' ? 'Reachable' : pulseStatus === 'unreachable' ? 'Unreachable' : 'Checking...'} + +
+ +
+ Error: + {endpoint.PulseError} +
+
+
+
+ ); + }}
diff --git a/frontend-modern/src/types/nodes.ts b/frontend-modern/src/types/nodes.ts index c64bb96..2642d36 100644 --- a/frontend-modern/src/types/nodes.ts +++ b/frontend-modern/src/types/nodes.ts @@ -8,8 +8,11 @@ export interface ClusterEndpoint { Host: string; GuestURL?: string; IP: string; - Online: boolean; + Online: boolean; // Proxmox's view: is the node online in the cluster? LastSeen: string; + PulseReachable?: boolean | null; // Pulse's view: can Pulse reach this endpoint? null/undefined = not yet checked + LastPulseCheck?: string | null; + PulseError?: string; // Last error Pulse encountered connecting to this endpoint } export interface PVENodeConfig { diff --git a/install.sh b/install.sh index 47125e4..b989058 100755 --- a/install.sh +++ b/install.sh @@ -1442,7 +1442,10 @@ fi'; then echo " Web UI: http://${IP}:${frontend_port}" echo " Container: $CTID" echo - echo " Commands:" + echo " First-time setup:" + echo " pct exec $CTID -- cat /etc/pulse/.bootstrap_token # Get bootstrap token" + echo + echo " Common commands:" echo " pct enter $CTID # Enter container" echo " pct exec $CTID -- update # Update Pulse" echo diff --git a/internal/config/config.go b/internal/config/config.go index 5495e1f..ea8c0b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -414,13 +414,16 @@ type PVEInstance struct { // ClusterEndpoint represents a single node in a cluster type ClusterEndpoint struct { - NodeID string // Node ID in cluster - NodeName string // Node name - Host string // Full URL (e.g., https://node1.lan:8006) - GuestURL string // Optional guest-accessible URL (for navigation) - IP string // IP address - Online bool // Current online status - LastSeen time.Time // Last successful connection + NodeID string // Node ID in cluster + NodeName string // Node name + Host string // Full URL (e.g., https://node1.lan:8006) + GuestURL string // Optional guest-accessible URL (for navigation) + IP string // IP address + Online bool // Current online status from Proxmox + LastSeen time.Time // Last successful connection + PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked + LastPulseCheck *time.Time // Last time Pulse checked connectivity + PulseError string // Last error Pulse encountered connecting to this endpoint } // PBSInstance represents a Proxmox Backup Server connection diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 48e0783..0a42b39 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -6007,6 +6007,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie onlineNodes[node.Name] = node.Status == "online" } + // Get Pulse connectivity status from ClusterClient if available + var pulseHealth map[string]proxmox.EndpointHealth + if clusterClient, ok := client.(*proxmox.ClusterClient); ok { + pulseHealth = clusterClient.GetHealthStatusWithErrors() + } + // Update the online status for each cluster endpoint for i := range instanceCfg.ClusterEndpoints { if online, exists := onlineNodes[instanceCfg.ClusterEndpoints[i].NodeName]; exists { @@ -6015,6 +6021,20 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie instanceCfg.ClusterEndpoints[i].LastSeen = time.Now() } } + + // Update Pulse connectivity status + if pulseHealth != nil { + // Try to find the endpoint in the health map by matching the effective URL + endpointURL := clusterEndpointEffectiveURL(instanceCfg.ClusterEndpoints[i]) + if health, exists := pulseHealth[endpointURL]; exists { + reachable := health.Healthy + instanceCfg.ClusterEndpoints[i].PulseReachable = &reachable + if !health.LastCheck.IsZero() { + instanceCfg.ClusterEndpoints[i].LastPulseCheck = &health.LastCheck + } + instanceCfg.ClusterEndpoints[i].PulseError = health.LastError + } + } } // Update the config with the new online status diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index 83dd5d2..f124298 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -146,7 +146,8 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost // Direct SSH (legacy method for non-containerized deployments) // Try sensors first, fall back to Raspberry Pi method if that fails - output, err = tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null") + // sensors exits non-zero when optional subfeatures fail; "|| true" keeps the JSON for parsing (#600) + output, err = tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null || true") if err != nil || strings.TrimSpace(output) == "" { if tc.disableLegacySSHOnAuthFailure(err, nodeName, host) { return &models.Temperature{Available: false}, nil diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index f04a12f..323f50a 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -21,6 +21,7 @@ type ClusterClient struct { endpoints []string // All available endpoints nodeHealth map[string]bool // Track node health lastHealthCheck map[string]time.Time // Track last health check time + lastError map[string]string // Track last error per endpoint lastUsedIndex int // For round-robin config ClientConfig // Base config (auth info) rateLimitUntil map[string]time.Time // Cooldown window for rate-limited endpoints @@ -51,6 +52,7 @@ func NewClusterClient(name string, config ClientConfig, endpoints []string) *Clu endpoints: endpoints, nodeHealth: make(map[string]bool), lastHealthCheck: make(map[string]time.Time), + lastError: make(map[string]string), config: config, rateLimitUntil: make(map[string]time.Time), } @@ -99,11 +101,13 @@ func (cc *ClusterClient) initialHealthCheck() { if err != nil { cc.mu.Lock() cc.nodeHealth[ep] = false + cc.lastError[ep] = err.Error() cc.lastHealthCheck[ep] = time.Now() cc.mu.Unlock() log.Info(). Str("cluster", cc.name). Str("endpoint", ep). + Err(err). Msg("Cluster endpoint marked unhealthy on initialization") return } @@ -133,6 +137,8 @@ func (cc *ClusterClient) initialHealthCheck() { fullClient, clientErr := NewClient(fullCfg) if clientErr != nil { cc.nodeHealth[ep] = false + cc.lastError[ep] = clientErr.Error() + cc.lastHealthCheck[ep] = time.Now() log.Warn(). Str("cluster", cc.name). Str("endpoint", ep). @@ -140,6 +146,8 @@ func (cc *ClusterClient) initialHealthCheck() { Msg("Failed to create full client after successful health check") } else { cc.nodeHealth[ep] = true + delete(cc.lastError, ep) + cc.lastHealthCheck[ep] = time.Now() cc.clients[ep] = fullClient // Store the full client, not test client if isVMSpecificError { log.Debug(). @@ -156,13 +164,14 @@ func (cc *ClusterClient) initialHealthCheck() { } else { // Real connectivity issue cc.nodeHealth[ep] = false + cc.lastError[ep] = err.Error() + cc.lastHealthCheck[ep] = time.Now() log.Info(). Str("cluster", cc.name). Str("endpoint", ep). Err(err). Msg("Cluster endpoint failed initial health check") } - cc.lastHealthCheck[ep] = time.Now() cc.mu.Unlock() }(endpoint) } @@ -353,6 +362,11 @@ func (cc *ClusterClient) getHealthyClient(ctx context.Context) (*Client, error) // markUnhealthy marks an endpoint as unhealthy func (cc *ClusterClient) markUnhealthy(endpoint string) { + cc.markUnhealthyWithError(endpoint, "") +} + +// markUnhealthyWithError marks an endpoint as unhealthy and captures the error +func (cc *ClusterClient) markUnhealthyWithError(endpoint string, errMsg string) { cc.mu.Lock() defer cc.mu.Unlock() @@ -360,9 +374,14 @@ func (cc *ClusterClient) markUnhealthy(endpoint string) { log.Warn(). Str("cluster", cc.name). Str("endpoint", endpoint). + Str("error", errMsg). Msg("Marking cluster node as unhealthy") cc.nodeHealth[endpoint] = false } + if errMsg != "" { + cc.lastError[endpoint] = errMsg + } + cc.lastHealthCheck[endpoint] = time.Now() } // recoverUnhealthyNodes attempts to recover unhealthy nodes @@ -456,6 +475,8 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) { cc.mu.Lock() cc.nodeHealth[ep] = true + delete(cc.lastError, ep) + cc.lastHealthCheck[ep] = time.Now() cc.clients[ep] = fullClient cc.mu.Unlock() @@ -624,7 +645,8 @@ func (cc *ClusterClient) executeWithFailover(ctx context.Context, fn func(*Clien } // Mark endpoint as unhealthy and try next - cc.markUnhealthy(clientEndpoint) + errMsg := err.Error() + cc.markUnhealthyWithError(clientEndpoint, errMsg) log.Warn(). Str("cluster", cc.name). @@ -734,6 +756,29 @@ func (cc *ClusterClient) GetHealthStatus() map[string]bool { return status } +// EndpointHealth contains health information for a single endpoint +type EndpointHealth struct { + Healthy bool + LastCheck time.Time + LastError string +} + +// GetHealthStatusWithErrors returns detailed health status including error messages +func (cc *ClusterClient) GetHealthStatusWithErrors() map[string]EndpointHealth { + cc.mu.RLock() + defer cc.mu.RUnlock() + + status := make(map[string]EndpointHealth) + for endpoint, healthy := range cc.nodeHealth { + status[endpoint] = EndpointHealth{ + Healthy: healthy, + LastCheck: cc.lastHealthCheck[endpoint], + LastError: cc.lastError[endpoint], + } + } + return status +} + // Implement all the Client methods with failover func (cc *ClusterClient) GetNodes(ctx context.Context) ([]Node, error) {