Improve Docker temperature monitoring documentation for clarity (related to #600)

Updated the Quick Start for Docker section in TEMPERATURE_MONITORING.md to be
more user-friendly and address common setup issues:

- Added clear explanation of why the proxy is needed (containers can't access hardware)
- Provided concrete IP example instead of placeholder
- Showed full docker-compose.yml context with proper YAML structure
- Added sudo to commands where needed
- Updated docker-compose commands to v2 syntax with note about v1
- Expanded verification steps with clearer success indicators
- Added reminder to check container name in verification commands

These improvements should help users who encounter blank temperature displays
due to missing proxy installation or bind mount configuration.
This commit is contained in:
rcourtman 2025-11-07 15:09:42 +00:00
parent 7ee252bd84
commit 48fabdd827
9 changed files with 186 additions and 46 deletions

View file

@ -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 | | Docker container | `/data/.bootstrap_token` inside the container or the mounted host volume |
| Helm / Kubernetes | The persistent volume mounted at `/data` | | 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 <ctid>
cat /etc/pulse/.bootstrap_token
# Or retrieve token directly
pct exec <ctid> -- 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). 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). 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. 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.

View file

@ -23,52 +23,76 @@ Pulse can display real-time CPU and NVMe temperatures directly in your dashboard
## Quick Start for Docker Deployments ## 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 ### 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 ```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \ sudo 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 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 ### 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 ```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: volumes:
- pulse-data:/data pulse-data:
- /run/pulse-sensor-proxy:/run/pulse-sensor-proxy:rw # Add this line
``` ```
This connects the proxy socket from your host into the container so Pulse can communicate with it.
### 3. Restart Pulse container ### 3. Restart Pulse container
```bash ```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 ```bash
# Verify proxy is running on host sudo systemctl status pulse-sensor-proxy
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"
``` ```
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` 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. **Having issues?** See [Troubleshooting](#troubleshooting) below.
--- ---

View file

@ -148,22 +148,50 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</span> </span>
</div> </div>
<Show when={clusterEndpoints().length > 0}> <Show when={clusterEndpoints().length > 0}>
<div class="flex flex-wrap gap-1"> <div class="flex flex-col gap-2">
<For each={clusterEndpoints()}> <For each={clusterEndpoints()}>
{(endpoint) => ( {(endpoint) => {
<span const pulseStatus = endpoint.PulseReachable === null || endpoint.PulseReachable === undefined
class={`inline-flex items-center gap-2 rounded border px-2 py-1 text-[0.7rem] ${ ? 'unknown'
endpoint.Online : endpoint.PulseReachable
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300' ? 'reachable'
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400' : 'unreachable';
}`}
> const statusColor = endpoint.Online && pulseStatus === 'reachable'
<span class="font-medium">{endpoint.NodeName}</span> ? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
<span class="text-[0.65rem] text-gray-500 dark:text-gray-400"> : pulseStatus === 'unreachable'
{endpoint.IP} ? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300'
</span> : endpoint.Online
</span> ? '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 (
<div class={`rounded border px-3 py-2 text-[0.7rem] ${statusColor}`}>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold">{endpoint.NodeName}</span>
<span class="text-[0.65rem] opacity-75">{endpoint.IP}</span>
</div>
<div class="flex flex-col gap-0.5 text-[0.65rem] opacity-90">
<div class="flex items-center gap-1.5">
<span class="w-16 font-medium">Proxmox:</span>
<span>{endpoint.Online ? 'Online' : 'Offline'}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-16 font-medium">Pulse:</span>
<span>
{pulseStatus === 'reachable' ? 'Reachable' : pulseStatus === 'unreachable' ? 'Unreachable' : 'Checking...'}
</span>
</div>
<Show when={pulseStatus === 'unreachable' && endpoint.PulseError}>
<div class="mt-1 pt-1 border-t border-current/20">
<span class="font-medium">Error: </span>
<span class="opacity-80">{endpoint.PulseError}</span>
</div>
</Show>
</div>
</div>
);
}}
</For> </For>
</div> </div>
</Show> </Show>

View file

@ -8,8 +8,11 @@ export interface ClusterEndpoint {
Host: string; Host: string;
GuestURL?: string; GuestURL?: string;
IP: string; IP: string;
Online: boolean; Online: boolean; // Proxmox's view: is the node online in the cluster?
LastSeen: string; 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 { export interface PVENodeConfig {

View file

@ -1442,7 +1442,10 @@ fi'; then
echo " Web UI: http://${IP}:${frontend_port}" echo " Web UI: http://${IP}:${frontend_port}"
echo " Container: $CTID" echo " Container: $CTID"
echo 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 enter $CTID # Enter container"
echo " pct exec $CTID -- update # Update Pulse" echo " pct exec $CTID -- update # Update Pulse"
echo echo

View file

@ -414,13 +414,16 @@ type PVEInstance struct {
// ClusterEndpoint represents a single node in a cluster // ClusterEndpoint represents a single node in a cluster
type ClusterEndpoint struct { type ClusterEndpoint struct {
NodeID string // Node ID in cluster NodeID string // Node ID in cluster
NodeName string // Node name NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006) Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation) GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address IP string // IP address
Online bool // Current online status Online bool // Current online status from Proxmox
LastSeen time.Time // Last successful connection 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 // PBSInstance represents a Proxmox Backup Server connection

View file

@ -6007,6 +6007,12 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
onlineNodes[node.Name] = node.Status == "online" 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 // Update the online status for each cluster endpoint
for i := range instanceCfg.ClusterEndpoints { for i := range instanceCfg.ClusterEndpoints {
if online, exists := onlineNodes[instanceCfg.ClusterEndpoints[i].NodeName]; exists { 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() 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 // Update the config with the new online status

View file

@ -146,7 +146,8 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost
// Direct SSH (legacy method for non-containerized deployments) // Direct SSH (legacy method for non-containerized deployments)
// Try sensors first, fall back to Raspberry Pi method if that fails // 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 err != nil || strings.TrimSpace(output) == "" {
if tc.disableLegacySSHOnAuthFailure(err, nodeName, host) { if tc.disableLegacySSHOnAuthFailure(err, nodeName, host) {
return &models.Temperature{Available: false}, nil return &models.Temperature{Available: false}, nil

View file

@ -21,6 +21,7 @@ type ClusterClient struct {
endpoints []string // All available endpoints endpoints []string // All available endpoints
nodeHealth map[string]bool // Track node health nodeHealth map[string]bool // Track node health
lastHealthCheck map[string]time.Time // Track last health check time lastHealthCheck map[string]time.Time // Track last health check time
lastError map[string]string // Track last error per endpoint
lastUsedIndex int // For round-robin lastUsedIndex int // For round-robin
config ClientConfig // Base config (auth info) config ClientConfig // Base config (auth info)
rateLimitUntil map[string]time.Time // Cooldown window for rate-limited endpoints 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, endpoints: endpoints,
nodeHealth: make(map[string]bool), nodeHealth: make(map[string]bool),
lastHealthCheck: make(map[string]time.Time), lastHealthCheck: make(map[string]time.Time),
lastError: make(map[string]string),
config: config, config: config,
rateLimitUntil: make(map[string]time.Time), rateLimitUntil: make(map[string]time.Time),
} }
@ -99,11 +101,13 @@ func (cc *ClusterClient) initialHealthCheck() {
if err != nil { if err != nil {
cc.mu.Lock() cc.mu.Lock()
cc.nodeHealth[ep] = false cc.nodeHealth[ep] = false
cc.lastError[ep] = err.Error()
cc.lastHealthCheck[ep] = time.Now() cc.lastHealthCheck[ep] = time.Now()
cc.mu.Unlock() cc.mu.Unlock()
log.Info(). log.Info().
Str("cluster", cc.name). Str("cluster", cc.name).
Str("endpoint", ep). Str("endpoint", ep).
Err(err).
Msg("Cluster endpoint marked unhealthy on initialization") Msg("Cluster endpoint marked unhealthy on initialization")
return return
} }
@ -133,6 +137,8 @@ func (cc *ClusterClient) initialHealthCheck() {
fullClient, clientErr := NewClient(fullCfg) fullClient, clientErr := NewClient(fullCfg)
if clientErr != nil { if clientErr != nil {
cc.nodeHealth[ep] = false cc.nodeHealth[ep] = false
cc.lastError[ep] = clientErr.Error()
cc.lastHealthCheck[ep] = time.Now()
log.Warn(). log.Warn().
Str("cluster", cc.name). Str("cluster", cc.name).
Str("endpoint", ep). Str("endpoint", ep).
@ -140,6 +146,8 @@ func (cc *ClusterClient) initialHealthCheck() {
Msg("Failed to create full client after successful health check") Msg("Failed to create full client after successful health check")
} else { } else {
cc.nodeHealth[ep] = true cc.nodeHealth[ep] = true
delete(cc.lastError, ep)
cc.lastHealthCheck[ep] = time.Now()
cc.clients[ep] = fullClient // Store the full client, not test client cc.clients[ep] = fullClient // Store the full client, not test client
if isVMSpecificError { if isVMSpecificError {
log.Debug(). log.Debug().
@ -156,13 +164,14 @@ func (cc *ClusterClient) initialHealthCheck() {
} else { } else {
// Real connectivity issue // Real connectivity issue
cc.nodeHealth[ep] = false cc.nodeHealth[ep] = false
cc.lastError[ep] = err.Error()
cc.lastHealthCheck[ep] = time.Now()
log.Info(). log.Info().
Str("cluster", cc.name). Str("cluster", cc.name).
Str("endpoint", ep). Str("endpoint", ep).
Err(err). Err(err).
Msg("Cluster endpoint failed initial health check") Msg("Cluster endpoint failed initial health check")
} }
cc.lastHealthCheck[ep] = time.Now()
cc.mu.Unlock() cc.mu.Unlock()
}(endpoint) }(endpoint)
} }
@ -353,6 +362,11 @@ func (cc *ClusterClient) getHealthyClient(ctx context.Context) (*Client, error)
// markUnhealthy marks an endpoint as unhealthy // markUnhealthy marks an endpoint as unhealthy
func (cc *ClusterClient) markUnhealthy(endpoint string) { 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() cc.mu.Lock()
defer cc.mu.Unlock() defer cc.mu.Unlock()
@ -360,9 +374,14 @@ func (cc *ClusterClient) markUnhealthy(endpoint string) {
log.Warn(). log.Warn().
Str("cluster", cc.name). Str("cluster", cc.name).
Str("endpoint", endpoint). Str("endpoint", endpoint).
Str("error", errMsg).
Msg("Marking cluster node as unhealthy") Msg("Marking cluster node as unhealthy")
cc.nodeHealth[endpoint] = false cc.nodeHealth[endpoint] = false
} }
if errMsg != "" {
cc.lastError[endpoint] = errMsg
}
cc.lastHealthCheck[endpoint] = time.Now()
} }
// recoverUnhealthyNodes attempts to recover unhealthy nodes // recoverUnhealthyNodes attempts to recover unhealthy nodes
@ -456,6 +475,8 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
cc.mu.Lock() cc.mu.Lock()
cc.nodeHealth[ep] = true cc.nodeHealth[ep] = true
delete(cc.lastError, ep)
cc.lastHealthCheck[ep] = time.Now()
cc.clients[ep] = fullClient cc.clients[ep] = fullClient
cc.mu.Unlock() cc.mu.Unlock()
@ -624,7 +645,8 @@ func (cc *ClusterClient) executeWithFailover(ctx context.Context, fn func(*Clien
} }
// Mark endpoint as unhealthy and try next // Mark endpoint as unhealthy and try next
cc.markUnhealthy(clientEndpoint) errMsg := err.Error()
cc.markUnhealthyWithError(clientEndpoint, errMsg)
log.Warn(). log.Warn().
Str("cluster", cc.name). Str("cluster", cc.name).
@ -734,6 +756,29 @@ func (cc *ClusterClient) GetHealthStatus() map[string]bool {
return status 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 // Implement all the Client methods with failover
func (cc *ClusterClient) GetNodes(ctx context.Context) ([]Node, error) { func (cc *ClusterClient) GetNodes(ctx context.Context) ([]Node, error) {