diff --git a/internal/servicediscovery/availability_suggestion.go b/internal/servicediscovery/availability_suggestion.go index a8a516946..2d9b5de49 100644 --- a/internal/servicediscovery/availability_suggestion.go +++ b/internal/servicediscovery/availability_suggestion.go @@ -59,6 +59,31 @@ func SuggestAvailabilityProbe(discovery *ResourceDiscovery, hostIP string) *Avai } } + // 3. Fallback: try hostname/name against the defaults maps. + // This covers containers where the deep scan hasn't populated ServiceType yet. + hostnameNormalized := normalizeServiceTypeForLookup(discovery.Hostname) + if hostnameNormalized != "" && hostnameNormalized != normalized { + if defaults, matched, ok := lookupWebServiceDefault(hostnameNormalized); ok { + return &AvailabilityProbeSuggestion{ + Protocol: defaults.Protocol, + Address: hostIP, + Port: defaults.Port, + Path: defaults.Path, + ServiceName: pickServiceName(discovery, matched), + Reason: fmt.Sprintf("hostname match: %s", matched), + } + } + if port, matched, ok := lookupTCPServiceDefault(hostnameNormalized); ok { + return &AvailabilityProbeSuggestion{ + Protocol: "tcp", + Address: hostIP, + Port: port, + ServiceName: pickServiceName(discovery, matched), + Reason: fmt.Sprintf("hostname tcp match: %s", matched), + } + } + } + return nil } diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index 4eb0d468c..025fda07e 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -411,8 +411,9 @@ func (s *Service) SetAIAnalyzer(analyzer AIAnalyzer) { // When set, getSnapshot() uses ReadState to build infrastructure snapshots. func (s *Service) SetReadState(rs unifiedresources.ReadState) { s.mu.Lock() - defer s.mu.Unlock() s.readState = rs + s.mu.Unlock() + go s.backfillAvailabilitySuggestions(context.Background()) } // Start begins the background discovery service. @@ -588,6 +589,7 @@ func (s *Service) runDiscoveryLoop(ctx context.Context, stopCh <-chan struct{}, s.collectFingerprints(ctx) s.runAutomaticDiscoveryRefresh(ctx) + s.backfillAvailabilitySuggestions(ctx) s.mu.RLock() currentInterval := s.interval @@ -602,6 +604,7 @@ func (s *Service) runDiscoveryLoop(ctx context.Context, stopCh <-chan struct{}, case <-ticker.C: s.collectFingerprints(ctx) s.runAutomaticDiscoveryRefresh(ctx) + s.backfillAvailabilitySuggestions(ctx) case newInterval := <-s.intervalCh: // Interval changed - reset the ticker newInterval = normalizeDiscoveryInterval(newInterval) @@ -2289,19 +2292,18 @@ func (s *Service) getResourceExternalIP(req DiscoveryRequest) string { switch req.ResourceType { case ResourceTypeSystemContainer: for _, c := range snap.Containers { - if fmt.Sprintf("%d", c.VMID) == req.ResourceID && c.Node == requestTargetID { + if fmt.Sprintf("%d", c.VMID) == req.ResourceID { if len(c.IPAddresses) > 0 { return c.IPAddresses[0] } if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, c.Name, req.Hostname); candidate != "" { return candidate } - return "" } } case ResourceTypeVM: for _, vm := range snap.VMs { - if fmt.Sprintf("%d", vm.VMID) == req.ResourceID && vm.Node == requestTargetID { + if fmt.Sprintf("%d", vm.VMID) == req.ResourceID { if len(vm.IPAddresses) > 0 { return vm.IPAddresses[0] } @@ -3169,6 +3171,85 @@ func (s *Service) refreshSuggestedAvailabilityProbeFromState(d *ResourceDiscover return true } +func (s *Service) backfillAvailabilitySuggestions(ctx context.Context) { + if ctx == nil || ctx.Err() != nil { + return + } + if !s.hasStateAccess() || s.store == nil { + return + } + + discoveries, err := s.store.List() + if err != nil { + log.Warn().Err(err).Msg("Failed to list discoveries for availability suggestion backfill") + return + } + if len(discoveries) == 0 { + return + } + + const maxEmptyRetries = 6 + initialDelay := 10 * time.Second + + for attempt := 0; attempt < maxEmptyRetries; attempt++ { + snap, ok := s.getSnapshot() + if !ok { + return + } + hasInfra := len(snap.Containers) > 0 || len(snap.VMs) > 0 || len(snap.DockerHosts) > 0 + if hasInfra || attempt == maxEmptyRetries-1 { + if !hasInfra { + log.Warn().Msg("backfill: state snapshot still empty after retries; proceeding with best effort") + } + break + } + log.Info().Dur("delay", initialDelay).Int("attempt", attempt+1).Msg("backfill: state snapshot empty, waiting for monitor data") + select { + case <-ctx.Done(): + return + case <-time.After(initialDelay): + } + initialDelay *= 2 + } + + log.Info().Int("discoveries", len(discoveries)).Msg("backfill: processing discoveries") + updated := 0 + for _, d := range discoveries { + if ctx.Err() != nil { + break + } + if d.SuggestedAvailabilityProbe != nil { + continue + } + + resourceType, targetID, resourceID, err := ParseResourceID(d.ID) + if err != nil { + continue + } + + req := DiscoveryRequest{ + ResourceType: resourceType, + ResourceID: resourceID, + TargetID: targetID, + } + + externalIP := s.getResourceExternalIP(req) + suggestion := SuggestAvailabilityProbe(d, externalIP) + if suggestion != nil { + d.SuggestedAvailabilityProbe = suggestion + if err := s.store.Save(d); err != nil { + log.Warn().Err(err).Str("id", d.ID).Msg("Failed to save backfilled availability suggestion") + } else { + updated++ + } + } + } + + if updated > 0 { + log.Info().Int("count", updated).Msg("Backfilled availability probe suggestions") + } +} + // lookupHostnameFromState finds the hostname/name for a resource from state func (s *Service) lookupHostnameFromState(resourceType ResourceType, hostID, resourceID string, snap StateSnapshot) string { switch resourceType { diff --git a/internal/servicediscovery/url_suggestion.go b/internal/servicediscovery/url_suggestion.go index 5043da59c..6af07174b 100644 --- a/internal/servicediscovery/url_suggestion.go +++ b/internal/servicediscovery/url_suggestion.go @@ -42,6 +42,8 @@ var webServiceDefaults = map[string]webServiceDefault{ "domoticz": {8080, "http", ""}, "node-red": {1880, "http", ""}, "nodered": {1880, "http", ""}, + "zigbee2mqtt": {8080, "http", ""}, + "ntfy": {80, "http", ""}, // Monitoring "grafana": {3000, "http", ""},