mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
8c9000ac63
Two Proxmox connections can each have a node with the same name, which is common for MSPs running one px1 per site. The node temperature lookup fell back to the first host agent whose hostname matched the node name, even when that agent was linked to the other site's node, and even when the name was ambiguous enough that the agent linker had refused to link it. Site B's px1 then showed, alerted on and recorded history for site A's sensors. The cluster sensor cache leaked the same way: an agent's SSH readings for its cluster siblings were keyed by bare node name, so a site A sibling reading fed site B's node. The poller now hands the lookup the polled node, and the lookup works on that node's slot. The state folds the views of one machine reached through two connections (a cluster added twice, or a multi-homed host added by each address) into one node held under one view's ID, and the agent link follows it. Those views used to find their agent only through the loose hostname match, so a polled view the read state does not hold also takes the one same-named node proven to be the same machine. models.NodeObservationsSameMachine and models.HostAgentBridgesNodeViews give that proof from the evidence UpdateNodesForInstance folds on, and the state's agent corroboration check now shares its rule with them. Ambiguous candidates are declined, as the state declines an ambiguous alias. An agent linked to the slot is the node's agent. The hostname fallback only takes an agent with no link when no node outside the slot and no second unlinked agent has the name. Cluster sensor cache entries record their reporting agent and serve a node only when that agent's linked node shares one of the slot's connections, or belongs to a connection whose configured view of the node has a matching TLS fingerprint. LinkedNodeID holds the Proxmox source node ID for automatic, manual and continuity-restored links, so the link match needs no normalization. The physical-disk SMART merge already matches agents by node.LinkedAgentID within one instance and is unchanged.
532 lines
18 KiB
Go
532 lines
18 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
func resolveNodeConnectionInfo(instanceCfg *config.PVEInstance, discoveryCfg config.DiscoveryConfig, nodeName string) (string, string) {
|
|
connectionHost := instanceCfg.Host
|
|
guestURL := instanceCfg.GuestURL
|
|
if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 {
|
|
hasFingerprint := instanceCfg.Fingerprint != ""
|
|
for _, ep := range instanceCfg.ClusterEndpoints {
|
|
if strings.EqualFold(ep.NodeName, nodeName) {
|
|
if effective := clusterEndpointRuntimeURL(ep, instanceCfg.VerifySSL, hasFingerprint, discoveryCfg); effective != "" {
|
|
connectionHost = effective
|
|
}
|
|
if ep.GuestURL != "" {
|
|
guestURL = ep.GuestURL
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return connectionHost, guestURL
|
|
}
|
|
|
|
func (m *Monitor) determineNodeIDAndStatus(instanceName string, instanceCfg *config.PVEInstance, node proxmox.Node) (string, string) {
|
|
// Apply grace period for node status to prevent flapping
|
|
// The configured instance is the canonical provider identity. Normally a
|
|
// cluster name remains the stable scope used by earlier Pulse releases,
|
|
// but two unrelated clusters are allowed to share that display name. In
|
|
// that ambiguous case the instance name prevents cross-cluster resource
|
|
// collisions without churning IDs for established unambiguous clusters.
|
|
nodeID := m.pveNodeID(instanceName, instanceCfg, node.Node)
|
|
effectiveStatus := node.Status
|
|
now := time.Now()
|
|
gracePeriod := m.pveNodeOfflineGracePeriod()
|
|
|
|
m.mu.Lock()
|
|
if strings.ToLower(node.Status) == "online" {
|
|
// Node is online - update last-online timestamp
|
|
m.nodeLastOnline[nodeID] = now
|
|
} else {
|
|
// Node is reported as offline - check grace period
|
|
lastOnline, exists := m.nodeLastOnline[nodeID]
|
|
if exists && now.Sub(lastOnline) < gracePeriod {
|
|
// Still within grace period - preserve online status
|
|
effectiveStatus = "online"
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Str("node", node.Node).
|
|
Dur("timeSinceOnline", now.Sub(lastOnline)).
|
|
Dur("gracePeriod", gracePeriod).
|
|
Msg("Node offline but within grace period - preserving online status")
|
|
} else {
|
|
// Grace period expired or never seen online - mark as offline
|
|
if exists {
|
|
log.Info().
|
|
Str("instance", instanceName).
|
|
Str("node", node.Node).
|
|
Dur("timeSinceOnline", now.Sub(lastOnline)).
|
|
Msg("Node offline and grace period expired - marking as offline")
|
|
}
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
return nodeID, effectiveStatus
|
|
}
|
|
|
|
func (m *Monitor) pveNodeID(instanceName string, instanceCfg *config.PVEInstance, nativeName string) string {
|
|
if instanceCfg != nil && instanceCfg.IsCluster {
|
|
if identityID := config.PVEClusterNodeIdentityForName(instanceCfg, nativeName); identityID != "" {
|
|
return identityID
|
|
}
|
|
}
|
|
return m.pveNodeIdentityScope(instanceName, instanceCfg) + "-" + nativeName
|
|
}
|
|
|
|
// pveNodeTLSFingerprint resolves the TOFU-captured TLS certificate
|
|
// fingerprint recorded in configuration for one node's API endpoint. The
|
|
// fingerprint is per-machine identity evidence for node state aggregation, so
|
|
// a cluster node only inherits the fingerprint of its own named endpoint -
|
|
// never the instance-level fingerprint, which pins whichever member the
|
|
// connection URL happens to reach. Unknown stays empty and asserts nothing.
|
|
func pveNodeTLSFingerprint(instanceCfg *config.PVEInstance, nodeName string) string {
|
|
if instanceCfg == nil {
|
|
return ""
|
|
}
|
|
if !instanceCfg.IsCluster {
|
|
return strings.TrimSpace(instanceCfg.Fingerprint)
|
|
}
|
|
nodeName = strings.TrimSpace(nodeName)
|
|
match := ""
|
|
for _, endpoint := range instanceCfg.ClusterEndpoints {
|
|
if !strings.EqualFold(strings.TrimSpace(endpoint.NodeName), nodeName) {
|
|
continue
|
|
}
|
|
fingerprint := strings.TrimSpace(endpoint.Fingerprint)
|
|
if fingerprint == "" {
|
|
continue
|
|
}
|
|
if match != "" && !strings.EqualFold(match, fingerprint) {
|
|
// Ambiguous duplicate endpoint records fail unknown.
|
|
return ""
|
|
}
|
|
match = fingerprint
|
|
}
|
|
return match
|
|
}
|
|
|
|
func (m *Monitor) pveNodeUsesProviderScopedIdentity(instanceName string, instanceCfg *config.PVEInstance) bool {
|
|
if instanceCfg == nil || !instanceCfg.IsCluster || strings.TrimSpace(instanceCfg.ClusterName) == "" {
|
|
return false
|
|
}
|
|
return !strings.EqualFold(
|
|
strings.TrimSpace(m.pveNodeIdentityScope(instanceName, instanceCfg)),
|
|
strings.TrimSpace(instanceCfg.ClusterName),
|
|
)
|
|
}
|
|
|
|
// nodeTemperatureCarryFloor is the shortest time a node may keep showing its
|
|
// last temperature after collection stops returning one, sized to ride out the
|
|
// SSH collector's early failure backoff steps (30s, 1m, 2m).
|
|
const nodeTemperatureCarryFloor = 5 * time.Minute
|
|
|
|
// nodeTemperatureCarryWindow bounds how old a carried-over node temperature may
|
|
// be. Beyond it the reading no longer describes the node, so it stops being
|
|
// presented as available.
|
|
func (m *Monitor) nodeTemperatureCarryWindow() time.Duration {
|
|
return resourceStaleThresholdForPollInterval(effectivePVEPollingIntervalForConfig(m.config), nodeTemperatureCarryFloor)
|
|
}
|
|
|
|
func (m *Monitor) collectNodeTemperatureData(
|
|
ctx context.Context,
|
|
instanceName string,
|
|
instanceCfg *config.PVEInstance,
|
|
node proxmox.Node,
|
|
modelNode *models.Node,
|
|
prevInstanceNodes []models.Node,
|
|
effectiveStatus string,
|
|
) {
|
|
if modelNode == nil {
|
|
return
|
|
}
|
|
|
|
// Collect temperature data via SSH (non-blocking, best effort)
|
|
// Only attempt for online nodes when temperature monitoring is enabled
|
|
// Check per-node setting first, fall back to global setting
|
|
tempMonitoringEnabled := m.config.TemperatureMonitoringEnabled
|
|
if instanceCfg.TemperatureMonitoringEnabled != nil {
|
|
tempMonitoringEnabled = *instanceCfg.TemperatureMonitoringEnabled
|
|
}
|
|
if effectiveStatus == "online" && tempMonitoringEnabled {
|
|
// First, check if there's a matching host agent with temperature data.
|
|
// Host agent temperatures are preferred because they don't require SSH access.
|
|
// Match by the polled node's identity, not its name, so two connections'
|
|
// same-named nodes (e.g., "px1" at two sites) never share an agent.
|
|
hostAgentTemp := m.getHostAgentTemperatureForNode(*modelNode)
|
|
if hostAgentTemp != nil {
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Float64("cpuPackage", hostAgentTemp.CPUPackage).
|
|
Float64("cpuMax", hostAgentTemp.CPUMax).
|
|
Int("nvmeCount", len(hostAgentTemp.NVMe)).
|
|
Msg("Using temperature data from host agent")
|
|
}
|
|
|
|
// If no host agent temp or we need additional data (SMART), try SSH/proxy collection
|
|
var sshTemp *models.Temperature
|
|
var err error
|
|
if m.tempCollector != nil {
|
|
// Temperature collection is best-effort - use a short timeout to avoid blocking node polling
|
|
// Use context.Background() so the timeout is truly independent of the parent polling context
|
|
// If SSH is slow or unresponsive, we'll preserve previous temperature data
|
|
tempCtx, tempCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer tempCancel()
|
|
|
|
// Determine SSH hostname to use (most robust approach):
|
|
// Prefer the resolved host for this node, with cluster overrides when available.
|
|
sshHost := modelNode.Host
|
|
foundNodeEndpoint := false
|
|
|
|
if modelNode.IsClusterMember && instanceCfg.IsCluster {
|
|
// Try to find specific endpoint configuration for this node
|
|
if len(instanceCfg.ClusterEndpoints) > 0 {
|
|
hasFingerprint := instanceCfg.Fingerprint != ""
|
|
for _, ep := range instanceCfg.ClusterEndpoints {
|
|
if strings.EqualFold(ep.NodeName, node.Node) {
|
|
if effective := clusterEndpointEffectiveURL(ep, instanceCfg.VerifySSL, hasFingerprint); effective != "" {
|
|
sshHost = effective
|
|
foundNodeEndpoint = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no specific endpoint found, fall back to node name
|
|
if !foundNodeEndpoint {
|
|
sshHost = node.Node
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Str("instance", instanceCfg.Name).
|
|
Msg("Node endpoint not found in cluster metadata - falling back to node name for temperature collection")
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(sshHost) == "" {
|
|
sshHost = node.Node
|
|
}
|
|
|
|
// A recent host agent payload is authoritative for this node.
|
|
// Legacy SSH remains only as a fallback when no usable local
|
|
// temperature reading has arrived from the linked agent.
|
|
skipSSHCollection := shouldSkipTemperatureSSHCollection(hostAgentTemp)
|
|
|
|
if !skipSSHCollection {
|
|
sshTemp, err = m.tempCollector.CollectTemperature(tempCtx, sshHost, node.Node)
|
|
if err != nil && hostAgentTemp == nil {
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Str("sshHost", sshHost).
|
|
Bool("isCluster", modelNode.IsClusterMember).
|
|
Int("endpointCount", len(instanceCfg.ClusterEndpoints)).
|
|
Msg("Temperature collection failed - check SSH access")
|
|
}
|
|
}
|
|
|
|
// Debug: log SSH temp details before merge
|
|
if sshTemp != nil {
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Bool("sshTempAvailable", sshTemp.Available).
|
|
Bool("sshHasSMART", sshTemp.HasSMART).
|
|
Int("sshSMARTCount", len(sshTemp.SMART)).
|
|
Bool("sshHasNVMe", sshTemp.HasNVMe).
|
|
Int("sshNVMeCount", len(sshTemp.NVMe)).
|
|
Msg("SSH temperature data before merge")
|
|
} else {
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Msg("SSH temperature data is nil")
|
|
}
|
|
}
|
|
|
|
// Merge host agent and SSH temperatures
|
|
temp := mergeTemperatureData(hostAgentTemp, sshTemp)
|
|
|
|
if temp != nil && temp.Available {
|
|
// Get the current CPU temperature (prefer package, fall back to max)
|
|
currentTemp := temp.CPUPackage
|
|
if currentTemp == 0 && temp.CPUMax > 0 {
|
|
currentTemp = temp.CPUMax
|
|
}
|
|
|
|
// Find previous temperature data for this node to preserve min/max
|
|
var prevTemp *models.Temperature
|
|
for _, prevNode := range prevInstanceNodes {
|
|
if prevNode.ID == modelNode.ID && prevNode.Temperature != nil {
|
|
prevTemp = prevNode.Temperature
|
|
break
|
|
}
|
|
}
|
|
|
|
// Initialize or update min/max tracking
|
|
if prevTemp != nil && prevTemp.CPUMin > 0 {
|
|
// Preserve existing min/max and update if necessary
|
|
temp.CPUMin = prevTemp.CPUMin
|
|
temp.CPUMaxRecord = prevTemp.CPUMaxRecord
|
|
temp.MinRecorded = prevTemp.MinRecorded
|
|
temp.MaxRecorded = prevTemp.MaxRecorded
|
|
|
|
// Update min if current is lower
|
|
if currentTemp > 0 && currentTemp < temp.CPUMin {
|
|
temp.CPUMin = currentTemp
|
|
temp.MinRecorded = time.Now()
|
|
}
|
|
|
|
// Update max if current is higher
|
|
if currentTemp > temp.CPUMaxRecord {
|
|
temp.CPUMaxRecord = currentTemp
|
|
temp.MaxRecorded = time.Now()
|
|
}
|
|
} else if currentTemp > 0 {
|
|
// First reading - initialize min/max to current value
|
|
temp.CPUMin = currentTemp
|
|
temp.CPUMaxRecord = currentTemp
|
|
temp.MinRecorded = time.Now()
|
|
temp.MaxRecorded = time.Now()
|
|
}
|
|
|
|
modelNode.Temperature = temp
|
|
|
|
// Determine source for logging
|
|
tempSource := "ssh"
|
|
if hostAgentTemp != nil && sshTemp == nil {
|
|
tempSource = "agent"
|
|
} else if hostAgentTemp != nil && sshTemp != nil {
|
|
tempSource = "agent+ssh"
|
|
}
|
|
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Str("source", tempSource).
|
|
Float64("cpuPackage", temp.CPUPackage).
|
|
Float64("cpuMax", temp.CPUMax).
|
|
Float64("cpuMin", temp.CPUMin).
|
|
Float64("cpuMaxRecord", temp.CPUMaxRecord).
|
|
Int("nvmeCount", len(temp.NVMe)).
|
|
Msg("Collected temperature data")
|
|
} else {
|
|
// Temperature data returned but not available (temp != nil && !temp.Available)
|
|
// OR no temperature data from any source - preserve previous temperature if available
|
|
// This prevents the temperature column from flickering when collection temporarily fails
|
|
var prevTemp *models.Temperature
|
|
for _, prevNode := range prevInstanceNodes {
|
|
if prevNode.ID == modelNode.ID && prevNode.Temperature != nil && prevNode.Temperature.Available {
|
|
prevTemp = prevNode.Temperature
|
|
break
|
|
}
|
|
}
|
|
|
|
if m.carriedTemperatureOutlivesAgentLease(*modelNode, prevTemp, time.Now()) {
|
|
// The reading came from a host agent that has stopped reporting.
|
|
// Its reporting lease already rode out transient gaps, so
|
|
// carrying it would outlive that lease.
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Time("lastUpdate", prevTemp.LastUpdate).
|
|
Msg("Dropped temperature data (host agent stopped reporting)")
|
|
} else if prevTemp != nil && time.Since(prevTemp.LastUpdate) > m.nodeTemperatureCarryWindow() {
|
|
// Collection has stayed broken past the carry window. Re-presenting
|
|
// the old reading would let alerts, history and the UI treat it as
|
|
// a live measurement forever, so the node has no reading now.
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Bool("isCluster", modelNode.IsClusterMember).
|
|
Time("lastUpdate", prevTemp.LastUpdate).
|
|
Msg("Dropped stale temperature data (collection has not returned a reading within the carry window)")
|
|
} else if preserved := carriedNodeTemperature(prevTemp); preserved != nil {
|
|
// The carried copy keeps its original LastUpdate to indicate staleness.
|
|
modelNode.Temperature = preserved
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Bool("isCluster", modelNode.IsClusterMember).
|
|
Float64("cpuPackage", preserved.CPUPackage).
|
|
Time("lastUpdate", preserved.LastUpdate).
|
|
Msg("Preserved previous temperature data (current collection failed or unavailable)")
|
|
} else {
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Bool("isCluster", modelNode.IsClusterMember).
|
|
Msg("No temperature data available (collection failed, no previous data to preserve)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// carriedNodeTemperature returns the part of an earlier reading a node may
|
|
// keep presenting while collection fails: its CPU and GPU temperatures, with
|
|
// the original LastUpdate. Disk temperatures (SMART, NVMe) are left out. They
|
|
// also stamp the node's physical disks, which have their own freshness and
|
|
// standby state that a carried copy cannot follow: a disk that spun down would
|
|
// be stamped, and recorded, with its pre-sleep reading. Returns nil when the
|
|
// earlier reading held nothing else.
|
|
func carriedNodeTemperature(prev *models.Temperature) *models.Temperature {
|
|
if prev == nil || !prev.Available {
|
|
return nil
|
|
}
|
|
carried := *prev
|
|
carried.Cores = append([]models.CoreTemp(nil), prev.Cores...)
|
|
carried.GPU = append([]models.GPUTemp(nil), prev.GPU...)
|
|
carried.SMART = []models.DiskTemp{}
|
|
carried.HasSMART = false
|
|
carried.NVMe = []models.NVMeTemp{}
|
|
carried.HasNVMe = false
|
|
if !hasUsableTemperatureReading(&carried) {
|
|
return nil
|
|
}
|
|
return &carried
|
|
}
|
|
|
|
func (m *Monitor) applyNodePendingUpdates(ctx context.Context, instanceName string, client PVEClientInterface, node proxmox.Node, nodeID string, effectiveStatus string, modelNode *models.Node) {
|
|
if modelNode == nil {
|
|
return
|
|
}
|
|
modelNode.PendingUpdatesStatus = "not_checked"
|
|
modelNode.PendingUpdatesReason = ""
|
|
if effectiveStatus != "online" {
|
|
modelNode.PendingUpdatesReason = "node_offline"
|
|
return
|
|
}
|
|
|
|
// Poll pending apt updates (less frequently - every 30 minutes)
|
|
// Only for online nodes to avoid wasting API calls on offline nodes
|
|
if effectiveStatus == "online" {
|
|
now := time.Now()
|
|
m.mu.RLock()
|
|
if m.nodePendingUpdatesCache == nil {
|
|
m.mu.RUnlock()
|
|
m.mu.Lock()
|
|
if m.nodePendingUpdatesCache == nil {
|
|
m.nodePendingUpdatesCache = make(map[string]pendingUpdatesCache)
|
|
}
|
|
m.mu.Unlock()
|
|
m.mu.RLock()
|
|
}
|
|
cached, hasCached := m.nodePendingUpdatesCache[nodeID]
|
|
m.mu.RUnlock()
|
|
|
|
if !hasCached || now.Sub(cached.checkedAt) >= pendingUpdatesCacheTTL {
|
|
// Time to check for updates
|
|
pendingPkgs, err := client.GetNodePendingUpdates(ctx, node.Node)
|
|
if err != nil {
|
|
// Preserve the last successful value, but mark it stale rather than
|
|
// presenting a permission or reachability failure as a confirmed zero.
|
|
log.Debug().
|
|
Err(err).
|
|
Str("node", node.Node).
|
|
Str("instance", instanceName).
|
|
Msg("Could not check pending apt updates with configured Proxmox credentials")
|
|
modelNode.PendingUpdatesReason = pendingUpdatesFailureReason(err)
|
|
if hasCached {
|
|
modelNode.PendingUpdates = cached.count
|
|
modelNode.PendingUpdatesCheckedAt = cached.checkedAt
|
|
modelNode.PendingUpdatesStatus = "stale"
|
|
} else {
|
|
modelNode.PendingUpdatesStatus = "unavailable"
|
|
}
|
|
} else {
|
|
updateCount := len(pendingPkgs)
|
|
modelNode.PendingUpdates = updateCount
|
|
modelNode.PendingUpdatesCheckedAt = now
|
|
modelNode.PendingUpdatesStatus = "checked"
|
|
|
|
// Cache the result
|
|
m.mu.Lock()
|
|
m.nodePendingUpdatesCache[nodeID] = pendingUpdatesCache{
|
|
count: updateCount,
|
|
checkedAt: now,
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
log.Debug().
|
|
Str("node", node.Node).
|
|
Str("instance", instanceName).
|
|
Int("pendingUpdates", updateCount).
|
|
Msg("Checked pending apt updates")
|
|
}
|
|
} else {
|
|
// Use cached value
|
|
modelNode.PendingUpdates = cached.count
|
|
modelNode.PendingUpdatesCheckedAt = cached.checkedAt
|
|
modelNode.PendingUpdatesStatus = "checked"
|
|
}
|
|
}
|
|
}
|
|
|
|
func pendingUpdatesFailureReason(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
switch {
|
|
case strings.Contains(message, "403"), strings.Contains(message, "permission"):
|
|
return "permission_denied"
|
|
case strings.Contains(message, "no healthy nodes"), strings.Contains(message, "595"),
|
|
strings.Contains(message, "offline"), strings.Contains(message, "unavailable"),
|
|
strings.Contains(message, "connection"), strings.Contains(message, "timeout"):
|
|
return "source_unavailable"
|
|
default:
|
|
return "check_failed"
|
|
}
|
|
}
|
|
|
|
func (m *Monitor) recordNodePollMetrics(instanceName string, node proxmox.Node, modelNode *models.Node, nodeStart time.Time) {
|
|
if m.pollMetrics == nil || modelNode == nil {
|
|
return
|
|
}
|
|
|
|
nodeNameLabel := strings.TrimSpace(node.Node)
|
|
if nodeNameLabel == "" {
|
|
nodeNameLabel = strings.TrimSpace(modelNode.DisplayName)
|
|
}
|
|
if nodeNameLabel == "" {
|
|
nodeNameLabel = "unknown-node"
|
|
}
|
|
|
|
success := true
|
|
nodeErrReason := ""
|
|
health := strings.ToLower(strings.TrimSpace(modelNode.ConnectionHealth))
|
|
if health != "" && health != "healthy" {
|
|
success = false
|
|
nodeErrReason = fmt.Sprintf("connection health %s", health)
|
|
}
|
|
|
|
status := strings.ToLower(strings.TrimSpace(modelNode.Status))
|
|
if success && status != "" && status != "online" {
|
|
success = false
|
|
nodeErrReason = fmt.Sprintf("status %s", status)
|
|
}
|
|
|
|
var nodeErr error
|
|
if !success {
|
|
if nodeErrReason == "" {
|
|
nodeErrReason = "unknown node error"
|
|
}
|
|
nodeErr = stderrors.New(nodeErrReason)
|
|
}
|
|
|
|
m.pollMetrics.RecordNodeResult(NodePollResult{
|
|
InstanceName: instanceName,
|
|
InstanceType: "pve",
|
|
NodeName: nodeNameLabel,
|
|
Success: success,
|
|
Error: nodeErr,
|
|
StartTime: nodeStart,
|
|
EndTime: time.Now(),
|
|
})
|
|
}
|