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:
parent
7ee252bd84
commit
48fabdd827
9 changed files with 186 additions and 46 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue