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

View file

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

View file

@ -148,22 +148,50 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</span>
</div>
<Show when={clusterEndpoints().length > 0}>
<div class="flex flex-wrap gap-1">
<div class="flex flex-col gap-2">
<For each={clusterEndpoints()}>
{(endpoint) => (
<span
class={`inline-flex items-center gap-2 rounded border px-2 py-1 text-[0.7rem] ${
endpoint.Online
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400'
}`}
>
<span class="font-medium">{endpoint.NodeName}</span>
<span class="text-[0.65rem] text-gray-500 dark:text-gray-400">
{endpoint.IP}
</span>
</span>
)}
{(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 (
<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>
</div>
</Show>

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {