Fix node config API to preserve fields on partial updates

The PUT /api/config/nodes/{id} endpoint was corrupting node configurations
when making partial updates (e.g., updating just monitorPhysicalDisks):

- Authentication fields (tokenName, tokenValue, password) were being cleared
  when updating unrelated settings
- Name field was being blanked when not included in request
- Monitor* boolean fields were defaulting to false

Changes:
- Only update name field if explicitly provided in request
- Only switch authentication method when auth fields are explicitly provided
- Preserve existing auth credentials on non-auth updates
- Applied fix to all node types (PVE, PBS, PMG)

Also enables physical disk monitoring by default (opt-out instead of opt-in)
and preserves disk data between polling intervals.
This commit is contained in:
rcourtman 2025-10-12 17:50:55 +00:00
parent a328dbd8e6
commit c18cf3d4b8
3 changed files with 216 additions and 185 deletions

View file

@ -290,7 +290,7 @@ type NodeConfigRequest struct {
MonitorContainers bool `json:"monitorContainers,omitempty"` // PVE only MonitorContainers bool `json:"monitorContainers,omitempty"` // PVE only
MonitorStorage bool `json:"monitorStorage,omitempty"` // PVE only MonitorStorage bool `json:"monitorStorage,omitempty"` // PVE only
MonitorBackups bool `json:"monitorBackups,omitempty"` // PVE only MonitorBackups bool `json:"monitorBackups,omitempty"` // PVE only
MonitorPhysicalDisks bool `json:"monitorPhysicalDisks,omitempty"` // PVE only MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` // PVE only (nil = enabled by default)
MonitorDatastores bool `json:"monitorDatastores,omitempty"` // PBS only MonitorDatastores bool `json:"monitorDatastores,omitempty"` // PBS only
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` // PBS only MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` // PBS only
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` // PBS only MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` // PBS only
@ -318,7 +318,7 @@ type NodeResponse struct {
MonitorContainers bool `json:"monitorContainers,omitempty"` MonitorContainers bool `json:"monitorContainers,omitempty"`
MonitorStorage bool `json:"monitorStorage,omitempty"` MonitorStorage bool `json:"monitorStorage,omitempty"`
MonitorBackups bool `json:"monitorBackups,omitempty"` MonitorBackups bool `json:"monitorBackups,omitempty"`
MonitorPhysicalDisks bool `json:"monitorPhysicalDisks,omitempty"` MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"`
MonitorDatastores bool `json:"monitorDatastores,omitempty"` MonitorDatastores bool `json:"monitorDatastores,omitempty"`
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"`
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"`
@ -720,7 +720,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
MonitorContainers: true, MonitorContainers: true,
MonitorStorage: true, MonitorStorage: true,
MonitorBackups: true, MonitorBackups: true,
MonitorPhysicalDisks: true, MonitorPhysicalDisks: nil, // nil = enabled by default
Status: "connected", Status: "connected",
IsCluster: true, IsCluster: true,
ClusterName: "mock-cluster", ClusterName: "mock-cluster",
@ -746,7 +746,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
MonitorContainers: true, MonitorContainers: true,
MonitorStorage: true, MonitorStorage: true,
MonitorBackups: true, MonitorBackups: true,
MonitorPhysicalDisks: true, MonitorPhysicalDisks: nil, // nil = enabled by default
Status: "connected", Status: "connected",
IsCluster: false, // Not part of a cluster IsCluster: false, // Not part of a cluster
ClusterName: "", ClusterName: "",
@ -1437,7 +1437,11 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
// Update the node // Update the node
if nodeType == "pve" && index < len(h.config.PVEInstances) { if nodeType == "pve" && index < len(h.config.PVEInstances) {
pve := &h.config.PVEInstances[index] pve := &h.config.PVEInstances[index]
// Only update name if provided
if req.Name != "" {
pve.Name = req.Name pve.Name = req.Name
}
if req.Host != "" { if req.Host != "" {
host := req.Host host := req.Host
@ -1456,30 +1460,37 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
pve.Host = host pve.Host = host
} }
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" || req.TokenValue != "" { if req.TokenName != "" || req.TokenValue != "" {
// Switching to or updating token authentication
if req.TokenName != "" { if req.TokenName != "" {
pve.TokenName = req.TokenName pve.TokenName = req.TokenName
} }
if req.TokenValue != "" { if req.TokenValue != "" {
pve.TokenValue = req.TokenValue pve.TokenValue = req.TokenValue
} }
// When using token authentication, clear password to avoid conflicts // Clear password to avoid conflicts
pve.Password = "" pve.Password = ""
if req.User != "" { if req.User != "" {
pve.User = req.User pve.User = req.User
} }
} else { } else if req.Password != "" {
// Explicitly switching to password authentication
if req.User != "" { if req.User != "" {
pve.User = normalizePVEUser(req.User) pve.User = normalizePVEUser(req.User)
} else if pve.User != "" { } else if pve.User != "" {
pve.User = normalizePVEUser(pve.User) pve.User = normalizePVEUser(pve.User)
} }
if req.Password != "" {
pve.Password = req.Password pve.Password = req.Password
} // Clear token fields when switching to password auth
// Clear token fields when using password authentication
pve.TokenName = "" pve.TokenName = ""
pve.TokenValue = "" pve.TokenValue = ""
} else {
// No authentication changes - preserve existing auth fields
// Only normalize user if it exists
if pve.User != "" {
pve.User = normalizePVEUser(pve.User)
}
} }
pve.Fingerprint = req.Fingerprint pve.Fingerprint = req.Fingerprint
@ -1511,22 +1522,22 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
} }
pbs.Host = host pbs.Host = host
// Determine authentication method and clear the unused fields // Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" && req.TokenValue != "" { if req.TokenName != "" && req.TokenValue != "" {
// Using token authentication - clear user/password // Switching to token authentication
pbs.User = ""
pbs.Password = ""
pbs.TokenName = req.TokenName pbs.TokenName = req.TokenName
pbs.TokenValue = req.TokenValue pbs.TokenValue = req.TokenValue
} else if req.TokenName != "" { // Clear user/password when switching to token auth
// Token name provided without new value - keep existing token value
pbs.User = "" pbs.User = ""
pbs.Password = "" pbs.Password = ""
} else if req.TokenName != "" {
// Token name provided without new value - keep existing token value
pbs.TokenName = req.TokenName pbs.TokenName = req.TokenName
// Clear user/password when using token auth
pbs.User = ""
pbs.Password = ""
} else if req.Password != "" { } else if req.Password != "" {
// Using password authentication - clear token fields // Switching to password authentication
pbs.TokenName = ""
pbs.TokenValue = ""
pbs.Password = req.Password pbs.Password = req.Password
// Ensure user has realm for PBS // Ensure user has realm for PBS
pbsUser := req.User pbsUser := req.User
@ -1534,17 +1545,22 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
} }
pbs.User = pbsUser pbs.User = pbsUser
} else if req.User != "" { // Clear token fields when switching to password auth
// User provided without password - keep existing password if any
pbs.TokenName = "" pbs.TokenName = ""
pbs.TokenValue = "" pbs.TokenValue = ""
} else if req.User != "" {
// User provided - assume password auth but keep existing password
// Ensure user has realm for PBS // Ensure user has realm for PBS
pbsUser := req.User pbsUser := req.User
if !strings.Contains(req.User, "@") { if !strings.Contains(req.User, "@") {
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
} }
pbs.User = pbsUser pbs.User = pbsUser
// Clear token fields when using password auth
pbs.TokenName = ""
pbs.TokenValue = ""
} }
// else: No authentication changes - preserve existing auth fields
pbs.Fingerprint = req.Fingerprint pbs.Fingerprint = req.Fingerprint
pbs.VerifySSL = req.VerifySSL pbs.VerifySSL = req.VerifySSL
@ -1575,12 +1591,16 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
pmgInst.Host = host pmgInst.Host = host
} }
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" && req.TokenValue != "" { if req.TokenName != "" && req.TokenValue != "" {
pmgInst.User = "" // Switching to token authentication
pmgInst.Password = ""
pmgInst.TokenName = req.TokenName pmgInst.TokenName = req.TokenName
pmgInst.TokenValue = req.TokenValue pmgInst.TokenValue = req.TokenValue
} else { // Clear user/password when switching to token auth
pmgInst.User = ""
pmgInst.Password = ""
} else if req.Password != "" {
// Switching to password authentication
if req.User != "" { if req.User != "" {
user := req.User user := req.User
if !strings.Contains(user, "@") { if !strings.Contains(user, "@") {
@ -1588,14 +1608,22 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
} }
pmgInst.User = user pmgInst.User = user
} }
if req.Password != "" {
pmgInst.Password = req.Password pmgInst.Password = req.Password
// Clear token fields when switching to password auth
pmgInst.TokenName = ""
pmgInst.TokenValue = ""
} else if req.User != "" {
// User provided - assume password auth but keep existing password
user := req.User
if !strings.Contains(user, "@") {
user = user + "@pmg"
} }
if req.TokenName == "" && req.TokenValue == "" { pmgInst.User = user
// Clear token fields when using password auth
pmgInst.TokenName = "" pmgInst.TokenName = ""
pmgInst.TokenValue = "" pmgInst.TokenValue = ""
} }
} // else: No authentication changes - preserve existing auth fields
pmgInst.Fingerprint = req.Fingerprint pmgInst.Fingerprint = req.Fingerprint
pmgInst.VerifySSL = req.VerifySSL pmgInst.VerifySSL = req.VerifySSL

View file

@ -145,7 +145,7 @@ type PVEInstance struct {
MonitorContainers bool MonitorContainers bool
MonitorStorage bool MonitorStorage bool
MonitorBackups bool MonitorBackups bool
MonitorPhysicalDisks bool // Monitor physical disks (polled less frequently to avoid spinning up HDDs) MonitorPhysicalDisks *bool // Monitor physical disks (nil = enabled by default, can be explicitly disabled)
PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default) PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default)
// Cluster support // Cluster support

View file

@ -2145,8 +2145,13 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
} }
} }
// Poll physical disks for health monitoring (only if enabled and interval elapsed) // Poll physical disks for health monitoring (enabled by default unless explicitly disabled)
if instanceCfg.MonitorPhysicalDisks { // Skip if MonitorPhysicalDisks is explicitly set to false
if instanceCfg.MonitorPhysicalDisks != nil && !*instanceCfg.MonitorPhysicalDisks {
log.Debug().Str("instance", instanceName).Msg("Physical disk monitoring explicitly disabled")
// Keep any existing disk data visible (don't clear it)
} else {
// Enabled by default (when nil or true)
// Determine polling interval (default 5 minutes to avoid spinning up HDDs too frequently) // Determine polling interval (default 5 minutes to avoid spinning up HDDs too frequently)
pollingInterval := 5 * time.Minute pollingInterval := 5 * time.Minute
if instanceCfg.PhysicalDiskPollingMinutes > 0 { if instanceCfg.PhysicalDiskPollingMinutes > 0 {
@ -2317,11 +2322,9 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
Msg("Updating physical disks in state") Msg("Updating physical disks in state")
m.state.UpdatePhysicalDisks(instanceName, allDisks) m.state.UpdatePhysicalDisks(instanceName, allDisks)
} }
} else {
// Physical disk monitoring is disabled - clear any existing disk data for this instance
log.Debug().Str("instance", instanceName).Msg("Physical disk monitoring disabled - clearing disk data")
m.state.UpdatePhysicalDisks(instanceName, []models.PhysicalDisk{})
} }
// Note: Physical disk monitoring is now enabled by default with a 5-minute polling interval.
// Users can explicitly disable it in node settings. Disk data is preserved between polls.
// Update nodes with storage fallback if rootfs was not available // Update nodes with storage fallback if rootfs was not available
for i := range modelNodes { for i := range modelNodes {