Add SMART temperature collection for physical disks (related to #652)
Extends temperature monitoring to collect SMART temps for SATA/SAS disks, addressing issue #652 where physical disk temperatures showed as empty. Architecture: - Deploys pulse-sensor-wrapper.sh as SSH forced command on Proxmox nodes - Wrapper collects both CPU/GPU temps (sensors -j) and disk temps (smartctl) - Implements 30-min cache with background refresh to avoid performance impact - Uses smartctl -n standby,after to skip sleeping drives without waking them - Returns unified JSON: {sensors: {...}, smart: [...]} Backend changes: - Add DiskTemp model with device, serial, WWN, temperature, lastUpdated - Extend Temperature model with SMART []DiskTemp field and HasSMART flag - Add WWN field to PhysicalDisk for reliable disk matching - Update parseSensorsJSON to handle both legacy and new wrapper formats - Rewrite mergeNVMeTempsIntoDisks to match SMART temps by WWN → serial → devpath - Preserve legacy NVMe temperature support for backward compatibility Performance considerations: - SMART data cached for 30 minutes per node to avoid excessive smartctl calls - Background refresh prevents blocking temperature requests - Respects drive standby state to avoid spinning up idle arrays - Staggered disk scanning with 0.1s delay to avoid saturating SATA controllers Install script: - Deploys wrapper to /usr/local/bin/pulse-sensor-wrapper.sh - Updates SSH forced command from "sensors -j" to wrapper script - Backward compatible - falls back to direct sensors output if wrapper missing Testing note: - Requires real hardware with smartmontools installed for full functionality - Empty smart array returned gracefully when smartctl unavailable - Legacy sensor-only nodes continue working without changes
This commit is contained in:
parent
50cf34a2da
commit
2a79d57f73
4 changed files with 293 additions and 35 deletions
|
|
@ -491,6 +491,7 @@ type PhysicalDisk struct {
|
||||||
DevPath string `json:"devPath"` // /dev/nvme0n1, /dev/sda
|
DevPath string `json:"devPath"` // /dev/nvme0n1, /dev/sda
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
|
WWN string `json:"wwn"` // World Wide Name
|
||||||
Type string `json:"type"` // nvme, sata, sas
|
Type string `json:"type"` // nvme, sata, sas
|
||||||
Size int64 `json:"size"` // bytes
|
Size int64 `json:"size"` // bytes
|
||||||
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
|
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
|
||||||
|
|
@ -774,11 +775,13 @@ type Temperature struct {
|
||||||
MaxRecorded time.Time `json:"maxRecorded,omitempty"` // When maximum temperature was recorded
|
MaxRecorded time.Time `json:"maxRecorded,omitempty"` // When maximum temperature was recorded
|
||||||
Cores []CoreTemp `json:"cores,omitempty"` // Individual core temperatures
|
Cores []CoreTemp `json:"cores,omitempty"` // Individual core temperatures
|
||||||
GPU []GPUTemp `json:"gpu,omitempty"` // GPU temperatures
|
GPU []GPUTemp `json:"gpu,omitempty"` // GPU temperatures
|
||||||
NVMe []NVMeTemp `json:"nvme,omitempty"` // NVMe drive temperatures
|
NVMe []NVMeTemp `json:"nvme,omitempty"` // NVMe drive temperatures (legacy, from sensor proxy)
|
||||||
|
SMART []DiskTemp `json:"smart,omitempty"` // Physical disk temperatures from SMART data
|
||||||
Available bool `json:"available"` // Whether any temperature data is available
|
Available bool `json:"available"` // Whether any temperature data is available
|
||||||
HasCPU bool `json:"hasCPU"` // Whether CPU temperature data is available
|
HasCPU bool `json:"hasCPU"` // Whether CPU temperature data is available
|
||||||
HasGPU bool `json:"hasGPU"` // Whether GPU temperature data is available
|
HasGPU bool `json:"hasGPU"` // Whether GPU temperature data is available
|
||||||
HasNVMe bool `json:"hasNVMe"` // Whether NVMe temperature data is available
|
HasNVMe bool `json:"hasNVMe"` // Whether NVMe temperature data is available
|
||||||
|
HasSMART bool `json:"hasSMART"` // Whether SMART disk temperature data is available
|
||||||
LastUpdate time.Time `json:"lastUpdate"` // When this data was collected
|
LastUpdate time.Time `json:"lastUpdate"` // When this data was collected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +805,18 @@ type NVMeTemp struct {
|
||||||
Temp float64 `json:"temp"`
|
Temp float64 `json:"temp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiskTemp represents a physical disk temperature from SMART data
|
||||||
|
type DiskTemp struct {
|
||||||
|
Device string `json:"device"` // Device path (e.g., /dev/sda)
|
||||||
|
Serial string `json:"serial,omitempty"` // Disk serial number
|
||||||
|
WWN string `json:"wwn,omitempty"` // World Wide Name
|
||||||
|
Model string `json:"model,omitempty"` // Disk model
|
||||||
|
Type string `json:"type,omitempty"` // Transport type (sata, sas, nvme)
|
||||||
|
Temperature int `json:"temperature"` // Temperature in Celsius
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"` // When this reading was taken
|
||||||
|
StandbySkipped bool `json:"standbySkipped,omitempty"` // True if disk was in standby and not queried
|
||||||
|
}
|
||||||
|
|
||||||
// Metric represents a time-series metric
|
// Metric represents a time-series metric
|
||||||
type Metric struct {
|
type Metric struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
|
|
||||||
|
|
@ -121,31 +121,104 @@ func mergeNVMeTempsIntoDisks(disks []models.PhysicalDisk, nodes []models.Node) [
|
||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build temperature maps by node for both SMART and legacy NVMe data
|
||||||
|
smartTempsByNode := make(map[string][]models.DiskTemp)
|
||||||
nvmeTempsByNode := make(map[string][]models.NVMeTemp)
|
nvmeTempsByNode := make(map[string][]models.NVMeTemp)
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
if node.Temperature == nil || !node.Temperature.Available || len(node.Temperature.NVMe) == 0 {
|
if node.Temperature == nil || !node.Temperature.Available {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
temps := make([]models.NVMeTemp, len(node.Temperature.NVMe))
|
// Collect SMART temps (preferred source)
|
||||||
copy(temps, node.Temperature.NVMe)
|
if len(node.Temperature.SMART) > 0 {
|
||||||
sort.Slice(temps, func(i, j int) bool {
|
temps := make([]models.DiskTemp, len(node.Temperature.SMART))
|
||||||
return temps[i].Device < temps[j].Device
|
copy(temps, node.Temperature.SMART)
|
||||||
})
|
smartTempsByNode[node.Name] = temps
|
||||||
|
}
|
||||||
|
|
||||||
nvmeTempsByNode[node.Name] = temps
|
// Collect legacy NVMe temps as fallback
|
||||||
|
if len(node.Temperature.NVMe) > 0 {
|
||||||
|
temps := make([]models.NVMeTemp, len(node.Temperature.NVMe))
|
||||||
|
copy(temps, node.Temperature.NVMe)
|
||||||
|
sort.Slice(temps, func(i, j int) bool {
|
||||||
|
return temps[i].Device < temps[j].Device
|
||||||
|
})
|
||||||
|
nvmeTempsByNode[node.Name] = temps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(nvmeTempsByNode) == 0 {
|
if len(smartTempsByNode) == 0 && len(nvmeTempsByNode) == 0 {
|
||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := make([]models.PhysicalDisk, len(disks))
|
updated := make([]models.PhysicalDisk, len(disks))
|
||||||
copy(updated, disks)
|
copy(updated, disks)
|
||||||
|
|
||||||
|
// Process SMART temperatures first (preferred method)
|
||||||
|
for i := range updated {
|
||||||
|
smartTemps, ok := smartTempsByNode[updated[i].Node]
|
||||||
|
if !ok || len(smartTemps) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match by WWN (most reliable)
|
||||||
|
if updated[i].WWN != "" {
|
||||||
|
for _, temp := range smartTemps {
|
||||||
|
if temp.WWN != "" && strings.EqualFold(temp.WWN, updated[i].WWN) {
|
||||||
|
if temp.Temperature > 0 && !temp.StandbySkipped {
|
||||||
|
updated[i].Temperature = temp.Temperature
|
||||||
|
log.Debug().
|
||||||
|
Str("disk", updated[i].DevPath).
|
||||||
|
Str("wwn", updated[i].WWN).
|
||||||
|
Int("temp", temp.Temperature).
|
||||||
|
Msg("Matched SMART temperature by WWN")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to serial number match (case-insensitive)
|
||||||
|
if updated[i].Serial != "" && updated[i].Temperature == 0 {
|
||||||
|
for _, temp := range smartTemps {
|
||||||
|
if temp.Serial != "" && strings.EqualFold(temp.Serial, updated[i].Serial) {
|
||||||
|
if temp.Temperature > 0 && !temp.StandbySkipped {
|
||||||
|
updated[i].Temperature = temp.Temperature
|
||||||
|
log.Debug().
|
||||||
|
Str("disk", updated[i].DevPath).
|
||||||
|
Str("serial", updated[i].Serial).
|
||||||
|
Int("temp", temp.Temperature).
|
||||||
|
Msg("Matched SMART temperature by serial")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: match by device path (normalized)
|
||||||
|
if updated[i].Temperature == 0 {
|
||||||
|
normalizedDevPath := strings.TrimPrefix(updated[i].DevPath, "/dev/")
|
||||||
|
for _, temp := range smartTemps {
|
||||||
|
normalizedTempDev := strings.TrimPrefix(temp.Device, "/dev/")
|
||||||
|
if normalizedTempDev == normalizedDevPath {
|
||||||
|
if temp.Temperature > 0 && !temp.StandbySkipped {
|
||||||
|
updated[i].Temperature = temp.Temperature
|
||||||
|
log.Debug().
|
||||||
|
Str("disk", updated[i].DevPath).
|
||||||
|
Int("temp", temp.Temperature).
|
||||||
|
Msg("Matched SMART temperature by device path")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process legacy NVMe temperatures for disks that didn't get SMART data
|
||||||
disksByNode := make(map[string][]int)
|
disksByNode := make(map[string][]int)
|
||||||
for i := range updated {
|
for i := range updated {
|
||||||
if strings.EqualFold(updated[i].Type, "nvme") {
|
if strings.EqualFold(updated[i].Type, "nvme") && updated[i].Temperature == 0 {
|
||||||
disksByNode[updated[i].Node] = append(disksByNode[updated[i].Node], i)
|
disksByNode[updated[i].Node] = append(disksByNode[updated[i].Node], i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,9 +226,6 @@ func mergeNVMeTempsIntoDisks(disks []models.PhysicalDisk, nodes []models.Node) [
|
||||||
for nodeName, diskIndexes := range disksByNode {
|
for nodeName, diskIndexes := range disksByNode {
|
||||||
temps, ok := nvmeTempsByNode[nodeName]
|
temps, ok := nvmeTempsByNode[nodeName]
|
||||||
if !ok || len(temps) == 0 {
|
if !ok || len(temps) == 0 {
|
||||||
for _, idx := range diskIndexes {
|
|
||||||
updated[idx].Temperature = 0
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,10 +233,6 @@ func mergeNVMeTempsIntoDisks(disks []models.PhysicalDisk, nodes []models.Node) [
|
||||||
return updated[diskIndexes[i]].DevPath < updated[diskIndexes[j]].DevPath
|
return updated[diskIndexes[i]].DevPath < updated[diskIndexes[j]].DevPath
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, idx := range diskIndexes {
|
|
||||||
updated[idx].Temperature = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, diskIdx := range diskIndexes {
|
for idx, diskIdx := range diskIndexes {
|
||||||
if idx >= len(temps) {
|
if idx >= len(temps) {
|
||||||
break
|
break
|
||||||
|
|
@ -178,6 +244,10 @@ func mergeNVMeTempsIntoDisks(disks []models.PhysicalDisk, nodes []models.Node) [
|
||||||
}
|
}
|
||||||
|
|
||||||
updated[diskIdx].Temperature = int(math.Round(tempVal))
|
updated[diskIdx].Temperature = int(math.Round(tempVal))
|
||||||
|
log.Debug().
|
||||||
|
Str("disk", updated[diskIdx].DevPath).
|
||||||
|
Int("temp", updated[diskIdx].Temperature).
|
||||||
|
Msg("Matched legacy NVMe temperature by index")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5787,6 +5857,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
||||||
DevPath: disk.DevPath,
|
DevPath: disk.DevPath,
|
||||||
Model: disk.Model,
|
Model: disk.Model,
|
||||||
Serial: disk.Serial,
|
Serial: disk.Serial,
|
||||||
|
WWN: disk.WWN,
|
||||||
Type: disk.Type,
|
Type: disk.Type,
|
||||||
Size: disk.Size,
|
Size: disk.Size,
|
||||||
Health: disk.Health,
|
Health: disk.Health,
|
||||||
|
|
|
||||||
|
|
@ -302,32 +302,41 @@ func (tc *TemperatureCollector) disableLegacySSHOnAuthFailure(err error, nodeNam
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSensorsJSON parses the JSON output from `sensors -j`
|
// parseSensorsJSON parses the JSON output from the sensor wrapper
|
||||||
func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temperature, error) {
|
func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temperature, error) {
|
||||||
if strings.TrimSpace(jsonStr) == "" {
|
if strings.TrimSpace(jsonStr) == "" {
|
||||||
return nil, fmt.Errorf("empty sensors output")
|
return nil, fmt.Errorf("empty sensors output")
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensors -j output structure:
|
// Try to parse as wrapper format first: {sensors: {...}, smart: [...]}
|
||||||
// {
|
// Fall back to legacy format for backward compatibility
|
||||||
// "coretemp-isa-0000": {
|
var wrapperData struct {
|
||||||
// "Package id 0": {"temp1_input": 45.0},
|
Sensors map[string]interface{} `json:"sensors"`
|
||||||
// "Core 0": {"temp2_input": 43.0},
|
SMART []models.DiskTemp `json:"smart"`
|
||||||
// ...
|
}
|
||||||
// },
|
|
||||||
// "nvme-pci-0400": {
|
|
||||||
// "Composite": {"temp1_input": 38.9}
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
var sensorsData map[string]interface{}
|
var sensorsData map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &sensorsData); err != nil {
|
var smartData []models.DiskTemp
|
||||||
return nil, fmt.Errorf("failed to parse sensors JSON: %w", err)
|
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &wrapperData); err == nil && wrapperData.Sensors != nil {
|
||||||
|
// New wrapper format
|
||||||
|
sensorsData = wrapperData.Sensors
|
||||||
|
smartData = wrapperData.SMART
|
||||||
|
log.Debug().
|
||||||
|
Int("smartDisks", len(smartData)).
|
||||||
|
Msg("Parsed new wrapper format with SMART data")
|
||||||
|
} else {
|
||||||
|
// Legacy format: direct sensors -j output
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &sensorsData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse sensors JSON: %w", err)
|
||||||
|
}
|
||||||
|
log.Debug().Msg("Parsed legacy sensors format (no SMART data)")
|
||||||
}
|
}
|
||||||
|
|
||||||
temp := &models.Temperature{
|
temp := &models.Temperature{
|
||||||
Cores: []models.CoreTemp{},
|
Cores: []models.CoreTemp{},
|
||||||
NVMe: []models.NVMeTemp{},
|
NVMe: []models.NVMeTemp{},
|
||||||
|
SMART: smartData,
|
||||||
}
|
}
|
||||||
|
|
||||||
foundCPUChip := false
|
foundCPUChip := false
|
||||||
|
|
@ -405,9 +414,10 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
|
||||||
temp.HasCPU = foundCPUChip
|
temp.HasCPU = foundCPUChip
|
||||||
temp.HasNVMe = len(temp.NVMe) > 0
|
temp.HasNVMe = len(temp.NVMe) > 0
|
||||||
temp.HasGPU = len(temp.GPU) > 0
|
temp.HasGPU = len(temp.GPU) > 0
|
||||||
|
temp.HasSMART = len(temp.SMART) > 0
|
||||||
|
|
||||||
// Available means any temperature data exists (backward compatibility)
|
// Available means any temperature data exists (backward compatibility)
|
||||||
temp.Available = temp.HasCPU || temp.HasNVMe || temp.HasGPU
|
temp.Available = temp.HasCPU || temp.HasNVMe || temp.HasGPU || temp.HasSMART
|
||||||
|
|
||||||
// Log summary of what was detected
|
// Log summary of what was detected
|
||||||
if !foundCPUChip {
|
if !foundCPUChip {
|
||||||
|
|
@ -424,11 +434,13 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
|
||||||
Bool("hasCPU", temp.HasCPU).
|
Bool("hasCPU", temp.HasCPU).
|
||||||
Bool("hasNVMe", temp.HasNVMe).
|
Bool("hasNVMe", temp.HasNVMe).
|
||||||
Bool("hasGPU", temp.HasGPU).
|
Bool("hasGPU", temp.HasGPU).
|
||||||
|
Bool("hasSMART", temp.HasSMART).
|
||||||
Float64("cpuPackage", temp.CPUPackage).
|
Float64("cpuPackage", temp.CPUPackage).
|
||||||
Float64("cpuMax", temp.CPUMax).
|
Float64("cpuMax", temp.CPUMax).
|
||||||
Int("coreCount", len(temp.Cores)).
|
Int("coreCount", len(temp.Cores)).
|
||||||
Int("nvmeCount", len(temp.NVMe)).
|
Int("nvmeCount", len(temp.NVMe)).
|
||||||
Int("gpuCount", len(temp.GPU)).
|
Int("gpuCount", len(temp.GPU)).
|
||||||
|
Int("smartCount", len(temp.SMART)).
|
||||||
Msg("Temperature data parsed successfully")
|
Msg("Temperature data parsed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ configure_local_authorized_key() {
|
||||||
}
|
}
|
||||||
|
|
||||||
BINARY_PATH="/usr/local/bin/pulse-sensor-proxy"
|
BINARY_PATH="/usr/local/bin/pulse-sensor-proxy"
|
||||||
|
WRAPPER_SCRIPT="/usr/local/bin/pulse-sensor-wrapper.sh"
|
||||||
SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service"
|
SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service"
|
||||||
RUNTIME_DIR="/run/pulse-sensor-proxy"
|
RUNTIME_DIR="/run/pulse-sensor-proxy"
|
||||||
SOCKET_PATH="${RUNTIME_DIR}/pulse-sensor-proxy.sock"
|
SOCKET_PATH="${RUNTIME_DIR}/pulse-sensor-proxy.sock"
|
||||||
|
|
@ -783,6 +784,165 @@ fi
|
||||||
|
|
||||||
print_info "Socket ready at $SOCKET_PATH"
|
print_info "Socket ready at $SOCKET_PATH"
|
||||||
|
|
||||||
|
# Install sensor wrapper script for combined sensor and SMART data collection
|
||||||
|
print_info "Installing sensor wrapper script..."
|
||||||
|
cat > "$WRAPPER_SCRIPT" << 'WRAPPER_EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# pulse-sensor-wrapper.sh
|
||||||
|
# Combined sensor and SMART temperature collection for Pulse monitoring
|
||||||
|
#
|
||||||
|
# This script is deployed as the SSH forced command for the sensor proxy.
|
||||||
|
# It collects CPU/GPU temps via sensors and disk temps via smartctl,
|
||||||
|
# returning a unified JSON payload.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CACHE_DIR="/var/cache/pulse-sensor-proxy"
|
||||||
|
SMART_CACHE_TTL=1800 # 30 minutes
|
||||||
|
MAX_SMARTCTL_TIME=5 # seconds per disk
|
||||||
|
|
||||||
|
# Ensure cache directory exists
|
||||||
|
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Function to get cached SMART data
|
||||||
|
get_cached_smart() {
|
||||||
|
local cache_file="$CACHE_DIR/smart-temps.json"
|
||||||
|
local now=$(date +%s)
|
||||||
|
|
||||||
|
# Check if cache exists and is fresh
|
||||||
|
if [[ -f "$cache_file" ]]; then
|
||||||
|
local mtime=$(stat -c %Y "$cache_file" 2>/dev/null || echo 0)
|
||||||
|
local age=$((now - mtime))
|
||||||
|
|
||||||
|
if [[ $age -lt $SMART_CACHE_TTL ]]; then
|
||||||
|
cat "$cache_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cache miss or stale - return empty array and trigger background refresh
|
||||||
|
echo "[]"
|
||||||
|
|
||||||
|
# Trigger async refresh if not already running
|
||||||
|
if ! pgrep -f "pulse-sensor-wrapper-refresh" >/dev/null 2>&1; then
|
||||||
|
(refresh_smart_cache &)
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to refresh SMART cache in background
|
||||||
|
refresh_smart_cache() {
|
||||||
|
# Mark this process for detection
|
||||||
|
exec -a pulse-sensor-wrapper-refresh bash
|
||||||
|
|
||||||
|
local cache_file="$CACHE_DIR/smart-temps.json"
|
||||||
|
local temp_file="${cache_file}.tmp.$$"
|
||||||
|
local disks=()
|
||||||
|
|
||||||
|
# Find all physical disks (skip partitions, loop devices, etc.)
|
||||||
|
while IFS= read -r dev; do
|
||||||
|
[[ -b "$dev" ]] && disks+=("$dev")
|
||||||
|
done < <(lsblk -nd -o NAME,TYPE | awk '$2=="disk" {print "/dev/"$1}')
|
||||||
|
|
||||||
|
local results=()
|
||||||
|
|
||||||
|
for dev in "${disks[@]}"; do
|
||||||
|
# Use smartctl with standby check to avoid waking sleeping drives
|
||||||
|
# -n standby: skip if drive is in standby/sleep mode
|
||||||
|
# --json=o: output original smartctl JSON format
|
||||||
|
# timeout: prevent hanging on problematic drives
|
||||||
|
|
||||||
|
local output
|
||||||
|
if output=$(timeout ${MAX_SMARTCTL_TIME}s smartctl -n standby,after -A --json=o "$dev" 2>/dev/null); then
|
||||||
|
# Parse the JSON output
|
||||||
|
local temp=$(echo "$output" | jq -r '
|
||||||
|
.temperature.current //
|
||||||
|
(.ata_smart_attributes.table[] | select(.id == 194) | .raw.value) //
|
||||||
|
(.nvme_smart_health_information_log.temperature // empty)
|
||||||
|
' 2>/dev/null)
|
||||||
|
|
||||||
|
local serial=$(echo "$output" | jq -r '.serial_number // empty' 2>/dev/null)
|
||||||
|
local wwn=$(echo "$output" | jq -r '.wwn.naa // .wwn.oui // empty' 2>/dev/null)
|
||||||
|
local model=$(echo "$output" | jq -r '.model_name // .model_family // empty' 2>/dev/null)
|
||||||
|
local transport=$(echo "$output" | jq -r '.device.type // empty' 2>/dev/null)
|
||||||
|
|
||||||
|
# Only include if we got a valid temperature
|
||||||
|
if [[ -n "$temp" && "$temp" != "null" && "$temp" =~ ^[0-9]+$ ]]; then
|
||||||
|
local entry=$(jq -n \
|
||||||
|
--arg dev "$dev" \
|
||||||
|
--arg serial "$serial" \
|
||||||
|
--arg wwn "$wwn" \
|
||||||
|
--arg model "$model" \
|
||||||
|
--arg transport "$transport" \
|
||||||
|
--argjson temp "$temp" \
|
||||||
|
--arg updated "$(date -Iseconds)" \
|
||||||
|
'{
|
||||||
|
device: $dev,
|
||||||
|
serial: $serial,
|
||||||
|
wwn: $wwn,
|
||||||
|
model: $model,
|
||||||
|
type: $transport,
|
||||||
|
temperature: $temp,
|
||||||
|
lastUpdated: $updated,
|
||||||
|
standbySkipped: false
|
||||||
|
}')
|
||||||
|
results+=("$entry")
|
||||||
|
fi
|
||||||
|
elif echo "$output" | grep -q "standby"; then
|
||||||
|
# Drive is in standby - record it but don't wake it
|
||||||
|
local entry=$(jq -n \
|
||||||
|
--arg dev "$dev" \
|
||||||
|
--arg updated "$(date -Iseconds)" \
|
||||||
|
'{
|
||||||
|
device: $dev,
|
||||||
|
temperature: null,
|
||||||
|
lastUpdated: $updated,
|
||||||
|
standbySkipped: true
|
||||||
|
}')
|
||||||
|
results+=("$entry")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Small delay between disks to avoid saturating SATA controller
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build final JSON array
|
||||||
|
if [[ ${#results[@]} -gt 0 ]]; then
|
||||||
|
local json=$(printf '%s\n' "${results[@]}" | jq -s '.')
|
||||||
|
else
|
||||||
|
local json="[]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Atomic write to cache
|
||||||
|
echo "$json" > "$temp_file"
|
||||||
|
mv "$temp_file" "$cache_file"
|
||||||
|
chmod 644 "$cache_file" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
|
||||||
|
# Collect sensor data (CPU, GPU temps)
|
||||||
|
sensors_data=$(sensors -j 2>/dev/null || echo '{}')
|
||||||
|
|
||||||
|
# Get SMART data from cache
|
||||||
|
smart_data=$(get_cached_smart)
|
||||||
|
|
||||||
|
# Combine into unified payload
|
||||||
|
jq -n \
|
||||||
|
--argjson sensors "$sensors_data" \
|
||||||
|
--argjson smart "$smart_data" \
|
||||||
|
'{
|
||||||
|
sensors: $sensors,
|
||||||
|
smart: $smart
|
||||||
|
}'
|
||||||
|
WRAPPER_EOF
|
||||||
|
|
||||||
|
chmod +x "$WRAPPER_SCRIPT"
|
||||||
|
print_success "Sensor wrapper installed at $WRAPPER_SCRIPT"
|
||||||
|
|
||||||
# Install cleanup system for automatic SSH key removal when nodes are deleted
|
# Install cleanup system for automatic SSH key removal when nodes are deleted
|
||||||
print_info "Installing cleanup system..."
|
print_info "Installing cleanup system..."
|
||||||
|
|
||||||
|
|
@ -1022,7 +1182,7 @@ if command -v pvecm >/dev/null 2>&1; then
|
||||||
print_info "Discovered cluster nodes: $(echo $CLUSTER_NODES | tr '\n' ' ')"
|
print_info "Discovered cluster nodes: $(echo $CLUSTER_NODES | tr '\n' ' ')"
|
||||||
|
|
||||||
# Configure SSH key with forced command restriction
|
# Configure SSH key with forced command restriction
|
||||||
FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
FORCED_CMD='command="/usr/local/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
||||||
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
||||||
|
|
||||||
# Track SSH key push results
|
# Track SSH key push results
|
||||||
|
|
@ -1099,7 +1259,7 @@ if command -v pvecm >/dev/null 2>&1; then
|
||||||
print_info "No cluster detected, configuring standalone node..."
|
print_info "No cluster detected, configuring standalone node..."
|
||||||
|
|
||||||
# Configure SSH key with forced command restriction
|
# Configure SSH key with forced command restriction
|
||||||
FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
FORCED_CMD='command="/usr/local/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
||||||
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
||||||
|
|
||||||
print_info "Authorizing proxy key on localhost..."
|
print_info "Authorizing proxy key on localhost..."
|
||||||
|
|
@ -1113,7 +1273,7 @@ else
|
||||||
print_info "Configuring SSH key for localhost..."
|
print_info "Configuring SSH key for localhost..."
|
||||||
|
|
||||||
# Configure localhost as fallback
|
# Configure localhost as fallback
|
||||||
FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
FORCED_CMD='command="/usr/local/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
||||||
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
|
||||||
|
|
||||||
configure_local_authorized_key "$AUTH_LINE"
|
configure_local_authorized_key "$AUTH_LINE"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue