fix(discovery): backfill availability suggestions for existing discoveries

The refreshSuggestedAvailabilityProbeFromState method existed but was
never called, so existing discovery records never received availability
probe suggestions. This adds backfillAvailabilitySuggestions which:

- Triggers from SetReadState (goroutine) and runDiscoveryLoop (ticker)
- Retries with exponential backoff when the state snapshot is empty,
  waiting for the monitor to populate Proxmox data before proceeding
- Matches containers by VMID only (was VMID+Node, which failed in
  clusters where the discovery targetID differs from the container's
  node)

Also extends SuggestAvailabilityProbe with a hostname fallback: when
ServiceType is empty (deep scan not yet run), checks the discovery's
Hostname against webServiceDefaults/tcpServiceDefaults. This covers
containers like jellyfin, grafana, frigate, esphome, zigbee2mqtt,
homeassistant, mqtt, etc. in environments where background AI is
disabled.

Adds zigbee2mqtt and ntfy to webServiceDefaults.
This commit is contained in:
rcourtman 2026-06-26 23:56:15 +01:00
parent 3ea61e117d
commit fa3f57e6ba
3 changed files with 112 additions and 4 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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", ""},