Fix VM migration issue where custom alert thresholds are lost

Resolves #641

## Problem
When a VM migrates between Proxmox nodes, Pulse was treating it as a new
resource and discarding custom alert threshold overrides. This occurred
because guest IDs included the node name (e.g., `instance-node-VMID`),
causing the ID to change when the VM moved to a different node.

Users reported that after migrating a VM, previously disabled alerts
(e.g., memory threshold set to 0) would resume firing.

## Root Cause
Guest IDs were constructed as:
- Standalone: `node-VMID`
- Cluster: `instance-node-VMID`

When a VM migrated from node1 to node2, the ID changed from
`instance-node1-100` to `instance-node2-100`, causing:
- Alert threshold overrides to be orphaned (keyed by old ID)
- Guest metadata (custom URLs, descriptions) to be orphaned
- Active alerts to reference the wrong resource ID

## Solution
Changed guest ID format to be stable across node migrations:
- New format: `instance-VMID` (for both standalone and cluster)
- Retains uniqueness across instances while being node-independent
- Allows VMs to migrate freely without losing configuration

## Implementation

### Backend Changes
1. **Guest ID Construction** (`monitor_polling.go`):
   - Simplified to always use `instance-VMID` format
   - Removed node from the ID construction logic

2. **Alert Override Migration** (`alerts.go`):
   - Added lazy migration in `getGuestThresholds()`
   - Detects legacy ID formats and migrates to new format
   - Preserves user configurations automatically

3. **Guest Metadata Migration** (`guest_metadata.go`):
   - Added `GetWithLegacyMigration()` helper method
   - Called during VM/container polling to migrate metadata
   - Preserves custom URLs and descriptions

4. **Active Alerts Migration** (`alerts.go`):
   - Added migration logic in `LoadActiveAlerts()`
   - Translates legacy alert resource IDs to new format
   - Preserves alert acknowledgments across restarts

### Frontend Changes
5. **ID Construction Updates**:
   - `ThresholdsTable.tsx`: Updated fallback from `instance-node-vmid` to `instance-vmid`
   - `Dashboard.tsx`: Simplified guest ID construction
   - `GuestRow.tsx`: Updated `buildGuestId()` helper

## Migration Strategy
- **Lazy Migration**: Configs are migrated as guests are discovered
- **Backwards Compatible**: Old IDs are detected and automatically converted
- **Zero Downtime**: No manual intervention required
- **Persisted**: Migrated configs are saved on next config write cycle

## Testing Recommendations
After deployment:
1. Verify existing alert overrides still apply
2. Test VM migration - confirm thresholds persist
3. Check guest metadata (custom URLs) survive migration
4. Verify active alerts maintain acknowledgment state

## Related
- Addresses similar issues with guest metadata and active alert tracking
- Lays groundwork for any future guest-specific configuration features
- Aligns with project philosophy: correctness and UX over implementation complexity
This commit is contained in:
rcourtman 2025-11-06 10:27:15 +00:00
parent dfe960deb4
commit 20854256c3
7 changed files with 219 additions and 25 deletions

View file

@ -1136,7 +1136,7 @@ const [editingThresholds, setEditingThresholds] = createSignal<
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
const guests = (props.allGuests() ?? []).map((guest) => {
const guestId = guest.id || `${guest.instance}-${guest.node}-${guest.vmid}`;
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const override = overridesMap.get(guestId);
const overrideSeverity = override?.poweredOffSeverity;

View file

@ -1011,12 +1011,9 @@ export function Dashboard(props: DashboardProps) {
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
// Match backend ID generation logic: stable format is "instance-vmid"
const guestId =
guest.id ||
(guest.instance === guest.node
? `${guest.node}-${guest.vmid}`
: `${guest.instance}-${guest.node}-${guest.vmid}`);
guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];

View file

@ -26,10 +26,7 @@ const DEFAULT_FIRST_CELL_INDENT = 'pl-4';
const buildGuestId = (guest: Guest) => {
if (guest.id) return guest.id;
if (guest.instance === guest.node) {
return `${guest.node}-${guest.vmid}`;
}
return `${guest.instance}-${guest.node}-${guest.vmid}`;
return `${guest.instance}-${guest.vmid}`;
};
// Type guard for VM vs Container

View file

@ -6,6 +6,7 @@ import (
"math"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@ -7494,7 +7495,72 @@ func (m *Manager) getGuestThresholds(guest interface{}, guestID string) Threshol
}
// Finally check guest-specific overrides (highest priority)
if override, exists := m.config.Overrides[guestID]; exists {
// First try the new stable ID format (instance-VMID)
override, exists := m.config.Overrides[guestID]
// If not found, try legacy ID formats for migration
if !exists {
var legacyID string
var node string
var vmid int
var instance string
// Extract node, vmid, and instance from the guest object
switch g := guest.(type) {
case models.VM:
node = g.Node
vmid = g.VMID
instance = g.Instance
case models.Container:
node = g.Node
vmid = g.VMID
instance = g.Instance
default:
// Not a VM or container, skip legacy migration
goto skipLegacyMigration
}
// Try legacy format: instance-node-VMID
if instance != node {
legacyID = fmt.Sprintf("%s-%s-%d", instance, node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
// Config will be persisted on next save cycle
override = legacyOverride
exists = true
}
}
// If still not found, try standalone format: node-VMID
if !exists && instance == node {
legacyID = fmt.Sprintf("%s-%d", node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy standalone ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
// Config will be persisted on next save cycle
override = legacyOverride
exists = true
}
}
}
skipLegacyMigration:
if exists {
// Apply the disabled flag if set
if override.Disabled {
thresholds.Disabled = true
@ -7734,6 +7800,57 @@ func (m *Manager) LoadActiveAlerts() error {
seen := make(map[string]bool)
for _, alert := range alerts {
// Migrate legacy guest alert IDs (instance-node-VMID -> instance-VMID)
// Check if this is a guest-related alert by looking at common alert types
isGuestAlert := strings.Contains(alert.Type, "cpu") || strings.Contains(alert.Type, "memory") ||
strings.Contains(alert.Type, "disk") || strings.Contains(alert.Type, "network") ||
alert.Type == "guest-offline"
if isGuestAlert {
// Try to extract instance, node, and VMID from resource ID
// Legacy format: instance-node-VMID or node-VMID (standalone)
parts := strings.Split(alert.ResourceID, "-")
// Check if this looks like a legacy format (has node in the ID)
// We can detect this if we have Node field and it appears in the ResourceID
if alert.Node != "" && len(parts) >= 2 {
var newResourceID string
var vmidStr string
// Try to extract VMID (should be last part)
vmidStr = parts[len(parts)-1]
if _, err := strconv.Atoi(vmidStr); err == nil {
// VMID is valid, now check if we need to migrate
if len(parts) == 3 && alert.Instance != "" && alert.Instance != alert.Node {
// Format: instance-node-VMID -> instance-VMID
newResourceID = fmt.Sprintf("%s-%s", alert.Instance, vmidStr)
} else if len(parts) == 2 && alert.Instance == alert.Node {
// Format: node-VMID -> instance-VMID (standalone)
newResourceID = fmt.Sprintf("%s-%s", alert.Instance, vmidStr)
}
if newResourceID != "" && newResourceID != alert.ResourceID {
log.Info().
Str("oldID", alert.ResourceID).
Str("newID", newResourceID).
Str("alertType", alert.Type).
Msg("Migrating active alert from legacy guest ID format")
oldAlertID := alert.ID
// Update resource ID
alert.ResourceID = newResourceID
// Update alert ID (usually contains resource ID)
alert.ID = strings.Replace(alert.ID, alert.ResourceID, newResourceID, 1)
// Update seen map to use new ID
seen[alert.ID] = true
delete(seen, oldAlertID)
}
}
}
}
// Skip duplicates
if seen[alert.ID] {
duplicateCount++

View file

@ -51,6 +51,85 @@ func (s *GuestMetadataStore) Get(guestID string) *GuestMetadata {
return nil
}
// GetWithLegacyMigration retrieves metadata for a guest, attempting legacy ID formats if needed
// and migrating them to the new stable format. This should be called when full guest context
// (node, instance, vmid) is available.
func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node string, vmid int) *GuestMetadata {
s.mu.RLock()
meta, exists := s.metadata[guestID]
s.mu.RUnlock()
if exists {
return meta
}
// Try legacy formats and migrate if found
var legacyID string
var legacyMeta *GuestMetadata
// Try legacy format: instance-node-VMID
if instance != node {
legacyID = fmt.Sprintf("%s-%s-%d", instance, node, vmid)
s.mu.RLock()
legacyMeta = s.metadata[legacyID]
s.mu.RUnlock()
if legacyMeta != nil {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest metadata from legacy ID format")
s.mu.Lock()
// Move to new ID
s.metadata[guestID] = legacyMeta
legacyMeta.ID = guestID
delete(s.metadata, legacyID)
// Save asynchronously
go func() {
if err := s.save(); err != nil {
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
}
}()
s.mu.Unlock()
return legacyMeta
}
}
// Try standalone format: node-VMID
if instance == node {
legacyID = fmt.Sprintf("%s-%d", node, vmid)
s.mu.RLock()
legacyMeta = s.metadata[legacyID]
s.mu.RUnlock()
if legacyMeta != nil {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest metadata from legacy standalone ID format")
s.mu.Lock()
// Move to new ID
s.metadata[guestID] = legacyMeta
legacyMeta.ID = guestID
delete(s.metadata, legacyID)
// Save asynchronously
go func() {
if err := s.save(); err != nil {
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
}
}()
s.mu.Unlock()
return legacyMeta
}
}
return nil
}
// GetAll retrieves all guest metadata
func (s *GuestMetadataStore) GetAll() map[string]*GuestMetadata {
s.mu.RLock()

View file

@ -442,6 +442,7 @@ type Monitor struct {
rng *rand.Rand
maxRetryAttempts int
tempCollector *TemperatureCollector // SSH-based temperature collector
guestMetadataStore *config.GuestMetadataStore
mu sync.RWMutex
startTime time.Time
rateTracker *RateTracker
@ -3199,6 +3200,7 @@ func New(cfg *config.Config) (*Monitor, error) {
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
maxRetryAttempts: 5,
tempCollector: tempCollector,
guestMetadataStore: config.NewGuestMetadataStore(cfg.DataPath),
startTime: time.Now(),
rateTracker: NewRateTracker(),
metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours

View file

@ -253,13 +253,9 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
tags = strings.Split(vm.Tags, ";")
}
// Create guest ID
var guestID string
if instanceName == n.Node {
guestID = fmt.Sprintf("%s-%d", n.Node, vm.VMID)
} else {
guestID = fmt.Sprintf("%s-%s-%d", instanceName, n.Node, vm.VMID)
}
// Create guest ID (stable across node migrations)
// Format: instance-VMID
guestID := fmt.Sprintf("%s-%d", instanceName, vm.VMID)
guestRaw := VMMemoryRaw{
ListingMem: vm.Mem,
@ -770,6 +766,11 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
modelVM.DiskWrite = 0
}
// Trigger guest metadata migration if old format exists
if m.guestMetadataStore != nil {
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, n.Node, vm.VMID)
}
nodeVMs = append(nodeVMs, modelVM)
m.recordGuestSnapshot(instanceName, modelVM.Type, n.Node, vm.VMID, GuestMemorySnapshot{
@ -929,13 +930,9 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
tags = strings.Split(container.Tags, ";")
}
// Create guest ID
var guestID string
if instanceName == n.Node {
guestID = fmt.Sprintf("%s-%d", n.Node, container.VMID)
} else {
guestID = fmt.Sprintf("%s-%s-%d", instanceName, n.Node, container.VMID)
}
// Create guest ID (stable across node migrations)
// Format: instance-VMID
guestID := fmt.Sprintf("%s-%d", instanceName, container.VMID)
// Calculate I/O rates
currentMetrics := IOMetrics{
@ -1045,6 +1042,11 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
modelContainer.DiskWrite = 0
}
// Trigger guest metadata migration if old format exists
if m.guestMetadataStore != nil {
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, n.Node, int(container.VMID))
}
nodeContainers = append(nodeContainers, modelContainer)
// Check alerts