diff --git a/frontend-modern/src/components/Dashboard/NodeCard.tsx b/frontend-modern/src/components/Dashboard/NodeCard.tsx index 0a34a294c..1a8539d30 100644 --- a/frontend-modern/src/components/Dashboard/NodeCard.tsx +++ b/frontend-modern/src/components/Dashboard/NodeCard.tsx @@ -207,7 +207,23 @@ const NodeCard: Component = (props) => { ↑{formatUptime(props.node.uptime)} - ⚡{normalizedLoad()} + ⚡{normalizedLoad()}} + > + 80 + ? 'text-red-500' + : (props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0) > 60 + ? 'text-yellow-500' + : 'text-green-500' + }`} + title={`CPU: ${Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C${props.node.temperature!.nvme && props.node.temperature!.nvme.length > 0 ? ` | NVMe: ${props.node.temperature!.nvme.map((n) => `${n.device}: ${Math.round(n.temp)}°C`).join(', ')}` : ''}`} + > + 🌡{Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C + + ); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 585b6bfa7..8eb687b3c 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -33,6 +33,7 @@ export interface Node { kernelVersion: string; pveVersion: string; cpuInfo: CPUInfo; + temperature?: Temperature; // CPU/NVMe temperatures lastSeen: string; connectionHealth: string; isClusterMember?: boolean; // True if part of a cluster @@ -275,6 +276,25 @@ export interface CPUInfo { mhz: string; } +export interface Temperature { + cpuPackage?: number; // CPU package temperature (primary metric) + cpuMax?: number; // Highest core temperature + cores?: CoreTemp[]; // Individual core temperatures + nvme?: NVMeTemp[]; // NVMe drive temperatures + available: boolean; // Whether temperature data is available + lastUpdate: string; // When this data was collected +} + +export interface CoreTemp { + core: number; + temp: number; +} + +export interface NVMeTemp { + device: string; + temp: number; +} + export interface Metric { timestamp: string; type: string; diff --git a/internal/models/converters.go b/internal/models/converters.go index 84ad5ca8b..fd9b633d3 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -79,6 +79,11 @@ func (n Node) ToFrontend() NodeFrontend { nf.Disk = &n.Disk } + // Include temperature data if available + if n.Temperature != nil && n.Temperature.Available { + nf.Temperature = n.Temperature + } + return nf } diff --git a/internal/models/models.go b/internal/models/models.go index ae427acf3..9056f2f56 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -51,24 +51,25 @@ type ResolvedAlert struct { // Node represents a Proxmox VE node type Node struct { - ID string `json:"id"` - Name string `json:"name"` - Instance string `json:"instance"` - Host string `json:"host"` // Full host URL from config - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - Memory Memory `json:"memory"` - Disk Disk `json:"disk"` - Uptime int64 `json:"uptime"` - LoadAverage []float64 `json:"loadAverage"` - KernelVersion string `json:"kernelVersion"` - PVEVersion string `json:"pveVersion"` - CPUInfo CPUInfo `json:"cpuInfo"` - LastSeen time.Time `json:"lastSeen"` - ConnectionHealth string `json:"connectionHealth"` - IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster - ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) + ID string `json:"id"` + Name string `json:"name"` + Instance string `json:"instance"` + Host string `json:"host"` // Full host URL from config + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + Memory Memory `json:"memory"` + Disk Disk `json:"disk"` + Uptime int64 `json:"uptime"` + LoadAverage []float64 `json:"loadAverage"` + KernelVersion string `json:"kernelVersion"` + PVEVersion string `json:"pveVersion"` + CPUInfo CPUInfo `json:"cpuInfo"` + Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures + LastSeen time.Time `json:"lastSeen"` + ConnectionHealth string `json:"connectionHealth"` + IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster + ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) } // VM represents a virtual machine @@ -318,6 +319,28 @@ type CPUInfo struct { MHz string `json:"mhz"` } +// Temperature represents temperature sensors data +type Temperature struct { + CPUPackage float64 `json:"cpuPackage,omitempty"` // CPU package temperature (primary metric) + CPUMax float64 `json:"cpuMax,omitempty"` // Highest core temperature + Cores []CoreTemp `json:"cores,omitempty"` // Individual core temperatures + NVMe []NVMeTemp `json:"nvme,omitempty"` // NVMe drive temperatures + Available bool `json:"available"` // Whether temperature data is available + LastUpdate time.Time `json:"lastUpdate"` // When this data was collected +} + +// CoreTemp represents a CPU core temperature +type CoreTemp struct { + Core int `json:"core"` + Temp float64 `json:"temp"` +} + +// NVMeTemp represents an NVMe drive temperature +type NVMeTemp struct { + Device string `json:"device"` + Temp float64 `json:"temp"` +} + // Metric represents a time-series metric type Metric struct { Timestamp time.Time `json:"timestamp"` diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index a2e4cc2b6..936559a12 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -5,25 +5,26 @@ package models // NodeFrontend represents a Node with frontend-friendly field names type NodeFrontend struct { - ID string `json:"id"` - Node string `json:"node"` // Maps to Name - Name string `json:"name"` - Instance string `json:"instance"` - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - Memory *Memory `json:"memory,omitempty"` // Full memory object with usage percentage - Mem int64 `json:"mem"` // Maps to Memory.Used (kept for backward compat) - MaxMem int64 `json:"maxmem"` // Maps to Memory.Total (kept for backward compat) - Disk *Disk `json:"disk,omitempty"` // Full disk object with usage percentage - MaxDisk int64 `json:"maxdisk"` // Maps to Disk.Total (kept for backward compat) - Uptime int64 `json:"uptime"` - LoadAverage []float64 `json:"loadAverage"` - KernelVersion string `json:"kernelVersion"` - PVEVersion string `json:"pveVersion"` - CPUInfo CPUInfo `json:"cpuInfo"` - LastSeen int64 `json:"lastSeen"` // Unix timestamp - ConnectionHealth string `json:"connectionHealth"` + ID string `json:"id"` + Node string `json:"node"` // Maps to Name + Name string `json:"name"` + Instance string `json:"instance"` + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + Memory *Memory `json:"memory,omitempty"` // Full memory object with usage percentage + Mem int64 `json:"mem"` // Maps to Memory.Used (kept for backward compat) + MaxMem int64 `json:"maxmem"` // Maps to Memory.Total (kept for backward compat) + Disk *Disk `json:"disk,omitempty"` // Full disk object with usage percentage + MaxDisk int64 `json:"maxdisk"` // Maps to Disk.Total (kept for backward compat) + Uptime int64 `json:"uptime"` + LoadAverage []float64 `json:"loadAverage"` + KernelVersion string `json:"kernelVersion"` + PVEVersion string `json:"pveVersion"` + CPUInfo CPUInfo `json:"cpuInfo"` + Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures + LastSeen int64 `json:"lastSeen"` // Unix timestamp + ConnectionHealth string `json:"connectionHealth"` } // VMFrontend represents a VM with frontend-friendly field names diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 3f631dc8c..b3fb08d7a 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -53,6 +53,7 @@ type Monitor struct { state *models.State pveClients map[string]PVEClientInterface pbsClients map[string]*pbs.Client + tempCollector *TemperatureCollector // SSH-based temperature collector mu sync.RWMutex startTime time.Time rateTracker *RateTracker @@ -175,11 +176,16 @@ func (m *Monitor) GetConnectionStatuses() map[string]bool { // New creates a new Monitor instance func New(cfg *config.Config) (*Monitor, error) { + // Initialize temperature collector with default SSH settings + // Will use root user for now - can be made configurable later + tempCollector := NewTemperatureCollector("root", "") + m := &Monitor{ config: cfg, state: models.NewState(), pveClients: make(map[string]PVEClientInterface), pbsClients: make(map[string]*pbs.Client), + tempCollector: tempCollector, startTime: time.Now(), rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours @@ -1097,6 +1103,25 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie } } + // Collect temperature data via SSH (non-blocking, best effort) + // Only attempt for online nodes + if node.Status == "online" && m.tempCollector != nil { + tempCtx, tempCancel := context.WithTimeout(ctx, 5*time.Second) + // Use node name as hostname (works for both cluster and standalone) + temp, err := m.tempCollector.CollectTemperature(tempCtx, node.Node, node.Node) + tempCancel() + + if err == nil && temp != nil && temp.Available { + modelNode.Temperature = temp + log.Debug(). + Str("node", node.Node). + Float64("cpuPackage", temp.CPUPackage). + Float64("cpuMax", temp.CPUMax). + Int("nvmeCount", len(temp.NVMe)). + Msg("Collected temperature data") + } + } + modelNodes = append(modelNodes, modelNode) } diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go new file mode 100644 index 000000000..a5e2d7013 --- /dev/null +++ b/internal/monitoring/temperature.go @@ -0,0 +1,250 @@ +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rs/zerolog/log" +) + +// TemperatureCollector handles SSH-based temperature collection from Proxmox nodes +type TemperatureCollector struct { + sshUser string // SSH user (typically "root" or "pulse-monitor") + sshKeyPath string // Path to SSH private key +} + +// NewTemperatureCollector creates a new temperature collector +func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector { + return &TemperatureCollector{ + sshUser: sshUser, + sshKeyPath: sshKeyPath, + } +} + +// CollectTemperature collects temperature data from a node via SSH +func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost, nodeName string) (*models.Temperature, error) { + // Extract hostname/IP from the host URL (might be https://hostname:8006) + host := extractHostname(nodeHost) + + // Try to get sensors JSON output + output, err := tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null") + if err != nil { + log.Debug(). + Str("node", nodeName). + Str("host", host). + Err(err). + Msg("Failed to collect temperature data via SSH") + return &models.Temperature{Available: false}, nil + } + + // Parse sensors JSON output + temp, err := tc.parseSensorsJSON(output) + if err != nil { + log.Debug(). + Str("node", nodeName). + Err(err). + Msg("Failed to parse sensors output") + return &models.Temperature{Available: false}, nil + } + + temp.Available = true + temp.LastUpdate = time.Now() + + return temp, nil +} + +// runSSHCommand executes a command on a remote node via SSH +func (tc *TemperatureCollector) runSSHCommand(ctx context.Context, host, command string) (string, error) { + // Build SSH command with appropriate options + sshArgs := []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "BatchMode=yes", // No password prompts + } + + // Add key if specified + if tc.sshKeyPath != "" { + sshArgs = append(sshArgs, "-i", tc.sshKeyPath) + } + + // Add user@host and command + sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", tc.sshUser, host), command) + + cmd := exec.CommandContext(ctx, "ssh", sshArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("ssh command failed: %w (output: %s)", err, string(output)) + } + + return string(output), nil +} + +// parseSensorsJSON parses the JSON output from `sensors -j` +func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temperature, error) { + if strings.TrimSpace(jsonStr) == "" { + return nil, fmt.Errorf("empty sensors output") + } + + // sensors -j output structure: + // { + // "coretemp-isa-0000": { + // "Package id 0": {"temp1_input": 45.0}, + // "Core 0": {"temp2_input": 43.0}, + // ... + // }, + // "nvme-pci-0400": { + // "Composite": {"temp1_input": 38.9} + // } + // } + + var sensorsData map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &sensorsData); err != nil { + return nil, fmt.Errorf("failed to parse sensors JSON: %w", err) + } + + temp := &models.Temperature{ + Cores: []models.CoreTemp{}, + NVMe: []models.NVMeTemp{}, + } + + // Parse each sensor chip + for chipName, chipData := range sensorsData { + chipMap, ok := chipData.(map[string]interface{}) + if !ok { + continue + } + + // Handle CPU temperature sensors (coretemp, k10temp, etc.) + if strings.Contains(chipName, "coretemp") || strings.Contains(chipName, "k10temp") { + tc.parseCPUTemps(chipMap, temp) + } + + // Handle NVMe temperature sensors + if strings.Contains(chipName, "nvme") { + tc.parseNVMeTemps(chipName, chipMap, temp) + } + } + + // If we got CPU temps, calculate max from cores if package not available + if temp.CPUPackage == 0 && len(temp.Cores) > 0 { + for _, core := range temp.Cores { + if core.Temp > temp.CPUMax { + temp.CPUMax = core.Temp + } + } + } + + return temp, nil +} + +// parseCPUTemps extracts CPU temperature data from a sensor chip +func (tc *TemperatureCollector) parseCPUTemps(chipMap map[string]interface{}, temp *models.Temperature) { + for sensorName, sensorData := range chipMap { + sensorMap, ok := sensorData.(map[string]interface{}) + if !ok { + continue + } + + // Look for Package id (Intel) or Tdie (AMD) + if strings.Contains(sensorName, "Package id") || strings.Contains(sensorName, "Tdie") { + if tempVal := extractTempInput(sensorMap); tempVal > 0 { + temp.CPUPackage = tempVal + } + } + + // Look for individual cores + if strings.HasPrefix(sensorName, "Core ") { + coreNum := extractCoreNumber(sensorName) + if tempVal := extractTempInput(sensorMap); tempVal > 0 { + temp.Cores = append(temp.Cores, models.CoreTemp{ + Core: coreNum, + Temp: tempVal, + }) + if tempVal > temp.CPUMax { + temp.CPUMax = tempVal + } + } + } + } +} + +// parseNVMeTemps extracts NVMe temperature data from a sensor chip +func (tc *TemperatureCollector) parseNVMeTemps(chipName string, chipMap map[string]interface{}, temp *models.Temperature) { + // Extract device name from chip name (e.g., "nvme-pci-0400" -> "nvme0") + device := "nvme" + strings.TrimPrefix(chipName, "nvme-pci-") + + for sensorName, sensorData := range chipMap { + sensorMap, ok := sensorData.(map[string]interface{}) + if !ok { + continue + } + + // Look for Composite temperature (main NVMe temp) + if strings.Contains(sensorName, "Composite") || strings.Contains(sensorName, "Sensor 1") { + if tempVal := extractTempInput(sensorMap); tempVal > 0 { + temp.NVMe = append(temp.NVMe, models.NVMeTemp{ + Device: device, + Temp: tempVal, + }) + break // Only one temp per NVMe device + } + } + } +} + +// extractTempInput extracts temperature value from sensor data +func extractTempInput(sensorMap map[string]interface{}) float64 { + // Look for temp*_input fields + for key, val := range sensorMap { + if strings.HasSuffix(key, "_input") { + switch v := val.(type) { + case float64: + return v + case int: + return float64(v) + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + } + } + } + return 0 +} + +// extractCoreNumber extracts the core number from a sensor name like "Core 0" +func extractCoreNumber(name string) int { + parts := strings.Fields(name) + if len(parts) >= 2 { + if num, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + return num + } + } + return 0 +} + +// extractHostname extracts hostname/IP from a Proxmox host URL +func extractHostname(hostURL string) string { + // Remove protocol + host := strings.TrimPrefix(hostURL, "https://") + host = strings.TrimPrefix(host, "http://") + + // Remove port + if idx := strings.Index(host, ":"); idx != -1 { + host = host[:idx] + } + + // Remove path + if idx := strings.Index(host, "/"); idx != -1 { + host = host[:idx] + } + + return host +}