diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index ad955d7..d213da8 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -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; diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index aa83aa7..7dfe7f7 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -1011,12 +1011,9 @@ export function Dashboard(props: DashboardProps) { }> {(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}`]; diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 6394b7f..d957339 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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 diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index ea5371e..bc4239e 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -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++ diff --git a/internal/config/guest_metadata.go b/internal/config/guest_metadata.go index dfe1ed3..cfc59c0 100644 --- a/internal/config/guest_metadata.go +++ b/internal/config/guest_metadata.go @@ -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() diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 05b23f4..055b33b 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -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 diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index 9bf5605..8e347c4 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -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