From d14bc41b6667e994ad70363134cd847fdac0c7d5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 26 Jun 2026 17:08:56 +0100 Subject: [PATCH] feat(discovery): suggest availability probes from discovered service types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery already identifies services and their default ports. Now it also suggests an availability probe configuration for each discovered service with a known web interface (webServiceDefaults) or TCP service (tcpServiceDefaults). The suggestion appears in the resource drawer as a card with a 'Monitor availability' button. On approval, it calls the existing POST /api/availability-targets API — one canonical system, no second management surface. Backend: - New SuggestAvailabilityProbe() generates protocol/port/path from the same webServiceDefaults map used for URL suggestions, with a tcpServiceDefaults fallback for databases and message brokers - New AvailabilityProbeSuggestion type on ResourceDiscovery - Wired into both discovery paths (DiscoverResource + Docker background) - Cached discoveries get the suggestion via refreshSuggestedAvailabilityProbe on read, so existing data picks it up without re-discovery Frontend: - AvailabilityProbeSuggestionCard in GuestDrawerOverview with one-click creation via AvailabilityTargetsAPI.create() - Card hidden when the resource already has an availability facet - Added 'https' to AvailabilityProbeProtocol type (backend already supports it) --- .../api/__tests__/availabilityTargets.test.ts | 17 +++ .../src/api/availabilityTargets.ts | 2 +- .../AvailabilityProbeSuggestionCard.tsx | 107 +++++++++++++ .../Workloads/GuestDrawerOverview.tsx | 9 ++ frontend-modern/src/types/discovery.ts | 10 ++ .../__tests__/discoveryPresentation.test.ts | 32 ++++ .../src/utils/discoveryPresentation.ts | 4 +- .../availability_suggestion.go | 117 +++++++++++++++ .../availability_suggestion_test.go | 140 ++++++++++++++++++ internal/servicediscovery/service.go | 26 +++- internal/servicediscovery/types.go | 19 +++ 11 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx create mode 100644 internal/servicediscovery/availability_suggestion.go create mode 100644 internal/servicediscovery/availability_suggestion_test.go diff --git a/frontend-modern/src/api/__tests__/availabilityTargets.test.ts b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts index 2205a65a6..244112d59 100644 --- a/frontend-modern/src/api/__tests__/availabilityTargets.test.ts +++ b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts @@ -63,4 +63,21 @@ describe('AvailabilityTargetsAPI', () => { }, ); }); + + it('accepts https protocol for secure web services', async () => { + const target: AvailabilityTarget = { + id: '', + name: 'Proxmox VE', + address: '192.0.2.5', + protocol: 'https', + port: 8006, + enabled: true, + }; + mockedApiFetchJSON.mockResolvedValueOnce(target); + await AvailabilityTargetsAPI.create(target); + expect(mockedApiFetchJSON).toHaveBeenCalledWith('/api/availability-targets', { + method: 'POST', + body: JSON.stringify(target), + }); + }); }); diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts index 127ba34a0..9151f88a7 100644 --- a/frontend-modern/src/api/availabilityTargets.ts +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -2,7 +2,7 @@ import { apiFetchJSON } from '@/utils/apiClient'; const AVAILABILITY_TARGETS_PATH = '/api/availability-targets'; -export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'http'; +export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'http' | 'https'; export type AvailabilityTargetKind = 'machine' | 'service' | 'device'; export interface AvailabilityProbeStatus { diff --git a/frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx b/frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx new file mode 100644 index 000000000..1a8dee09f --- /dev/null +++ b/frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx @@ -0,0 +1,107 @@ +import { Show, createSignal } from 'solid-js'; +import { Activity, Check, Loader2 } from 'lucide-solid'; + +import { + AvailabilityTargetsAPI, + type AvailabilityProbeProtocol, + type AvailabilityTarget, +} from '@/api/availabilityTargets'; +import type { AvailabilityProbeSuggestion } from '@/types/discovery'; +import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; + +interface AvailabilityProbeSuggestionCardProps { + suggestion: AvailabilityProbeSuggestion; + linkedResourceId: string; +} + +type State = 'idle' | 'creating' | 'created' | 'error'; + +export function AvailabilityProbeSuggestionCard(props: AvailabilityProbeSuggestionCardProps) { + const [state, setState] = createSignal('idle'); + const [error, setError] = createSignal(''); + + const protocolLabel = () => { + const proto = props.suggestion.protocol.toUpperCase(); + const port = props.suggestion.port ? ` :${props.suggestion.port}` : ''; + return `${proto}${port}`; + }; + + const handleCreate = async () => { + setState('creating'); + setError(''); + const s = props.suggestion; + const target: AvailabilityTarget = { + id: '', + name: s.service_name || `${s.protocol} probe`, + address: s.address, + protocol: s.protocol as AvailabilityProbeProtocol, + port: s.port || undefined, + path: s.protocol === 'http' || s.protocol === 'https' ? s.path || undefined : undefined, + linkedResourceId: props.linkedResourceId, + enabled: true, + }; + try { + await AvailabilityTargetsAPI.create(target); + setState('created'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create probe'); + setState('error'); + } + }; + + return ( + +
+
+
+ + Suggested + +
+
+ +
+ Service + + {props.suggestion.service_name} + +
+
+ Probe + {protocolLabel()} +
+
+ Target + + {props.suggestion.address} + +
+ +
{error()}
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 8b1efe816..90e96f827 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -11,6 +11,7 @@ import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes, formatUptime } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; +import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard'; import { DiskList } from './DiskList'; import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel'; import type { NestedWorkloadContext } from './nestedWorkloadContext'; @@ -472,6 +473,14 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { suggestedUrlReasonTitle={props.discoveryIdentifiedSummary?.suggestedUrlReasonTitle} suggestedUrlDiagnostic={props.discoveryIdentifiedSummary?.suggestedUrlDiagnostic} /> + +
+ +
+
); diff --git a/frontend-modern/src/types/discovery.ts b/frontend-modern/src/types/discovery.ts index 3659d54da..b9a61ef64 100644 --- a/frontend-modern/src/types/discovery.ts +++ b/frontend-modern/src/types/discovery.ts @@ -96,6 +96,16 @@ export interface ResourceDiscovery { suggested_url_source_code?: string; suggested_url_source_detail?: string; suggested_url_diagnostic?: string; + suggested_availability_probe?: AvailabilityProbeSuggestion; +} + +export interface AvailabilityProbeSuggestion { + protocol: string; + address: string; + port?: number; + path?: string; + service_name: string; + reason: string; } export interface DiscoverySummary { diff --git a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts index 4f679e8d7..e9e3752a2 100644 --- a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts @@ -222,6 +222,38 @@ describe('discoveryPresentation', () => { suggestedUrlReasonTitle: 'Detected web port: detected 3000/tcp', suggestedUrlDiagnostic: undefined, hasEndpointCandidate: true, + suggestedAvailabilityProbe: undefined, + }); + }); + + it('extracts suggested availability probe from discovery data', () => { + const summary = getDiscoveryIdentifiedSummary({ + id: 'system-container:node:124', + resource_type: 'system-container', + resource_id: '124', + target_id: 'node', + service_name: 'Grafana', + service_type: 'grafana', + confidence: 0.9, + ports: [{ port: 3000, protocol: 'tcp', process: 'grafana', address: '0.0.0.0' }], + facts: [{ category: 'service', key: 'status', value: 'running', source: 'systemd', confidence: 1, discovered_at: '2026-06-01T00:00:00Z' }], + config_paths: [], + data_paths: [], + log_paths: [], + suggested_availability_probe: { + protocol: 'http', + address: '192.0.2.10', + port: 3000, + service_name: 'Grafana', + reason: 'service default: grafana', + }, + } as unknown as ResourceDiscovery); + expect(summary?.suggestedAvailabilityProbe).toEqual({ + protocol: 'http', + address: '192.0.2.10', + port: 3000, + service_name: 'Grafana', + reason: 'service default: grafana', }); }); diff --git a/frontend-modern/src/utils/discoveryPresentation.ts b/frontend-modern/src/utils/discoveryPresentation.ts index 0fc4a4cf1..0f2a93c4d 100644 --- a/frontend-modern/src/utils/discoveryPresentation.ts +++ b/frontend-modern/src/utils/discoveryPresentation.ts @@ -1,4 +1,4 @@ -import type { DiscoveryFact, ResourceDiscovery } from '@/types/discovery'; +import type { AvailabilityProbeSuggestion, DiscoveryFact, ResourceDiscovery } from '@/types/discovery'; import { getInfrastructureSettingsLocationLabel, getInfrastructureSettingsTarget, @@ -24,6 +24,7 @@ export interface DiscoveryIdentifiedSummary { suggestedUrlReasonTitle?: string; suggestedUrlDiagnostic?: string; hasEndpointCandidate: boolean; + suggestedAvailabilityProbe?: AvailabilityProbeSuggestion; } const OBSERVED_SOURCE_LABEL = 'Observed by Discovery'; @@ -168,6 +169,7 @@ export function getDiscoveryIdentifiedSummary( suggestedUrlReasonTitle: suggestedUrlReason.title || undefined, suggestedUrlDiagnostic: discovery.suggested_url_diagnostic?.trim() || undefined, hasEndpointCandidate: Boolean(suggestedUrl), + suggestedAvailabilityProbe: discovery.suggested_availability_probe ?? undefined, }; } diff --git a/internal/servicediscovery/availability_suggestion.go b/internal/servicediscovery/availability_suggestion.go new file mode 100644 index 000000000..a8a516946 --- /dev/null +++ b/internal/servicediscovery/availability_suggestion.go @@ -0,0 +1,117 @@ +package servicediscovery + +import ( + "fmt" + "strings" +) + +// tcpServiceDefaults maps service types to their default TCP probe port. +// These are services without a web interface that are still commonly +// monitored for availability (databases, message brokers, caches). +var tcpServiceDefaults = map[string]int{ + "mqtt": 1883, + "mosquitto": 1883, + "postgres": 5432, + "postgresql": 5432, + "mysql": 3306, + "mariadb": 3306, + "redis": 6379, + "memcached": 11211, + "zookeeper": 2181, + "nats": 4222, + "etcd": 2379, + "rabbitmq": 5672, + "activemq": 61616, +} + +// SuggestAvailabilityProbe generates a suggested availability probe +// configuration for a discovered resource. It first checks webServiceDefaults +// (HTTP/HTTPS probes for services with web interfaces), then falls back to +// tcpServiceDefaults (TCP probes for databases and message brokers). +// Returns nil if no suitable probe can be suggested. +func SuggestAvailabilityProbe(discovery *ResourceDiscovery, hostIP string) *AvailabilityProbeSuggestion { + if discovery == nil || hostIP == "" { + return nil + } + + normalized := normalizeServiceTypeForLookup(discovery.ServiceType) + + // 1. Web services → HTTP/HTTPS probe + if defaults, matched, ok := lookupWebServiceDefault(normalized); ok { + return &AvailabilityProbeSuggestion{ + Protocol: defaults.Protocol, + Address: hostIP, + Port: defaults.Port, + Path: defaults.Path, + ServiceName: pickServiceName(discovery, matched), + Reason: fmt.Sprintf("service default: %s", matched), + } + } + + // 2. TCP-only services (databases, brokers) → TCP probe + if port, matched, ok := lookupTCPServiceDefault(normalized); ok { + return &AvailabilityProbeSuggestion{ + Protocol: "tcp", + Address: hostIP, + Port: port, + ServiceName: pickServiceName(discovery, matched), + Reason: fmt.Sprintf("tcp service default: %s", matched), + } + } + + return nil +} + +// normalizeServiceTypeForLookup normalizes a service type string for map lookup. +func normalizeServiceTypeForLookup(serviceType string) string { + s := strings.ToLower(strings.TrimSpace(serviceType)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + return s +} + +// lookupWebServiceDefault checks webServiceDefaults for an exact or variation match. +func lookupWebServiceDefault(normalized string) (webServiceDefault, string, bool) { + if defaults, ok := webServiceDefaults[normalized]; ok { + return defaults, normalized, true + } + for _, variation := range serviceTypeVariations(normalized) { + if defaults, ok := webServiceDefaults[variation]; ok { + return defaults, variation, true + } + } + return webServiceDefault{}, "", false +} + +// lookupTCPServiceDefault checks tcpServiceDefaults for an exact or variation match. +func lookupTCPServiceDefault(normalized string) (int, string, bool) { + if port, ok := tcpServiceDefaults[normalized]; ok { + return port, normalized, true + } + for _, variation := range serviceTypeVariations(normalized) { + if port, ok := tcpServiceDefaults[variation]; ok { + return port, variation, true + } + } + return 0, "", false +} + +// serviceTypeVariations generates common variations of a normalized service type +// for fuzzy map lookup. +func serviceTypeVariations(normalized string) []string { + return []string{ + normalized, + strings.ReplaceAll(normalized, "-", ""), + strings.TrimSuffix(normalized, "-server"), + strings.TrimSuffix(normalized, "server"), + } +} + +// pickServiceName returns the discovery's human-readable service name, falling +// back to the matched service type key. +func pickServiceName(discovery *ResourceDiscovery, matchedKey string) string { + if discovery.ServiceName != "" { + return discovery.ServiceName + } + return matchedKey +} diff --git a/internal/servicediscovery/availability_suggestion_test.go b/internal/servicediscovery/availability_suggestion_test.go new file mode 100644 index 000000000..fe4d8bb77 --- /dev/null +++ b/internal/servicediscovery/availability_suggestion_test.go @@ -0,0 +1,140 @@ +package servicediscovery + +import ( + "testing" +) + +func TestSuggestAvailabilityProbe_WebService(t *testing.T) { + tests := []struct { + name string + serviceType string + serviceName string + hostIP string + wantProtocol string + wantPort int + wantPath string + wantReasonSubstr string + }{ + {"grafana", "grafana", "Grafana", "10.0.0.1", "http", 3000, "", "service default"}, + {"homeassistant", "homeassistant", "Home Assistant", "10.0.0.2", "http", 8123, "", "service default"}, + {"esphome", "esphome", "ESPHome", "10.0.0.3", "http", 6052, "", "service default"}, + {"plex with path", "plex", "Plex", "10.0.0.4", "http", 32400, "/web", "service default"}, + {"pbs https", "pbs", "Proxmox Backup Server", "10.0.0.5", "https", 8007, "", "service default"}, + {"proxmox https", "proxmox", "Proxmox VE", "10.0.0.6", "https", 8006, "", "service default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &ResourceDiscovery{ + ServiceType: tt.serviceType, + ServiceName: tt.serviceName, + } + got := SuggestAvailabilityProbe(d, tt.hostIP) + if got == nil { + t.Fatalf("expected suggestion for %s, got nil", tt.serviceType) + } + if got.Protocol != tt.wantProtocol { + t.Errorf("protocol = %q, want %q", got.Protocol, tt.wantProtocol) + } + if got.Port != tt.wantPort { + t.Errorf("port = %d, want %d", got.Port, tt.wantPort) + } + if got.Path != tt.wantPath { + t.Errorf("path = %q, want %q", got.Path, tt.wantPath) + } + if got.Address != tt.hostIP { + t.Errorf("address = %q, want %q", got.Address, tt.hostIP) + } + if got.ServiceName != tt.serviceName { + t.Errorf("service_name = %q, want %q", got.ServiceName, tt.serviceName) + } + }) + } +} + +func TestSuggestAvailabilityProbe_TCPService(t *testing.T) { + tests := []struct { + name string + serviceType string + hostIP string + wantPort int + }{ + {"mqtt", "mqtt", "10.0.0.10", 1883}, + {"mosquitto", "mosquitto", "10.0.0.10", 1883}, + {"postgres", "postgres", "10.0.0.11", 5432}, + {"redis", "redis", "10.0.0.12", 6379}, + {"rabbitmq", "rabbitmq", "10.0.0.13", 5672}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &ResourceDiscovery{ + ServiceType: tt.serviceType, + ServiceName: tt.serviceType, + } + got := SuggestAvailabilityProbe(d, tt.hostIP) + if got == nil { + t.Fatalf("expected suggestion for %s, got nil", tt.serviceType) + } + if got.Protocol != "tcp" { + t.Errorf("protocol = %q, want tcp", got.Protocol) + } + if got.Port != tt.wantPort { + t.Errorf("port = %d, want %d", got.Port, tt.wantPort) + } + }) + } +} + +func TestSuggestAvailabilityProbe_NoSuggestion(t *testing.T) { + tests := []struct { + name string + discovery *ResourceDiscovery + hostIP string + }{ + {"nil discovery", nil, "10.0.0.1"}, + {"empty hostIP", &ResourceDiscovery{ServiceType: "grafana"}, ""}, + {"unknown service", &ResourceDiscovery{ServiceType: "custom-app"}, "10.0.0.1"}, + {"empty service type", &ResourceDiscovery{ServiceType: ""}, "10.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SuggestAvailabilityProbe(tt.discovery, tt.hostIP) + if got != nil { + t.Errorf("expected nil suggestion, got %+v", got) + } + }) + } +} + +func TestSuggestAvailabilityProbe_VariationMatch(t *testing.T) { + d := &ResourceDiscovery{ + ServiceType: "node-red", + ServiceName: "Node-RED", + } + got := SuggestAvailabilityProbe(d, "10.0.0.20") + if got == nil { + t.Fatal("expected suggestion for node-red variation, got nil") + } + if got.Protocol != "http" { + t.Errorf("protocol = %q, want http", got.Protocol) + } + if got.Port != 1880 { + t.Errorf("port = %d, want 1880", got.Port) + } +} + +func TestSuggestAvailabilityProbe_ServiceNameFallback(t *testing.T) { + d := &ResourceDiscovery{ + ServiceType: "grafana", + ServiceName: "", + } + got := SuggestAvailabilityProbe(d, "10.0.0.1") + if got == nil { + t.Fatal("expected suggestion, got nil") + } + if got.ServiceName != "grafana" { + t.Errorf("service_name = %q, want grafana (fallback to matched key)", got.ServiceName) + } +} diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index 19cac2c2b..4eb0d468c 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -1379,8 +1379,9 @@ func (s *Service) discoverDockerContainers(ctx context.Context, hosts []DockerHo discovery = s.enhanceWithDeepScan(ctx, discovery, host) } - // Suggest web interface URL using Docker host hostname + // Suggest web interface URL and availability probe using Docker host hostname discovery.SuggestedURL = SuggestWebURL(discovery, host.Hostname) + discovery.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(discovery, host.Hostname) if err := s.store.Save(discovery); err != nil { log.Warn().Err(err).Str("id", id).Msg("failed to save discovery") @@ -1984,6 +1985,10 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (* } } } + // Suggest availability probe using the same external IP + if externalIP != "" { + discovery.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(discovery, externalIP) + } } discovery.SuggestedURLSourceCode = urlSuggestionSourceCode discovery.SuggestedURLSourceDetail = urlSuggestionSourceDetail @@ -3103,6 +3108,9 @@ func (s *Service) upgradeCLIAccessIfNeeded(d *ResourceDiscovery) { if s.refreshSuggestedURLFromState(d, req) { upgraded = true } + if s.refreshSuggestedAvailabilityProbeFromState(d, req) { + upgraded = true + } _ = upgraded // Suppress unused variable warning if logging is disabled } @@ -3145,6 +3153,22 @@ func (s *Service) refreshSuggestedURLFromState(d *ResourceDiscovery, req Discove return changed } +func (s *Service) refreshSuggestedAvailabilityProbeFromState(d *ResourceDiscovery, req DiscoveryRequest) bool { + if d == nil || d.SuggestedAvailabilityProbe != nil || !s.hasStateAccess() { + return false + } + externalIP := s.getResourceExternalIP(req) + if externalIP == "" { + return false + } + suggestion := SuggestAvailabilityProbe(d, externalIP) + if suggestion == nil { + return false + } + d.SuggestedAvailabilityProbe = suggestion + return true +} + // 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/types.go b/internal/servicediscovery/types.go index a7d91db06..726303fb9 100644 --- a/internal/servicediscovery/types.go +++ b/internal/servicediscovery/types.go @@ -169,6 +169,25 @@ type ResourceDiscovery struct { SuggestedURLSourceCode string `json:"suggested_url_source_code,omitempty"` SuggestedURLSourceDetail string `json:"suggested_url_source_detail,omitempty"` SuggestedURLDiagnostic string `json:"suggested_url_diagnostic,omitempty"` + + // Auto-suggested availability probe derived from the discovered service type + // and known default ports. When approved by the user this becomes a canonical + // availability target via POST /api/availability-targets. + SuggestedAvailabilityProbe *AvailabilityProbeSuggestion `json:"suggested_availability_probe,omitempty"` +} + +// AvailabilityProbeSuggestion represents a suggested availability probe +// configuration derived from discovery data. The suggestion feed is read-only; +// the user approves it into the canonical availability target store via the +// existing POST /api/availability-targets API. There is no second management +// surface. +type AvailabilityProbeSuggestion struct { + Protocol string `json:"protocol"` // "http", "https", "tcp" + Address string `json:"address"` // IP or hostname + Port int `json:"port,omitempty"` + Path string `json:"path,omitempty"` + ServiceName string `json:"service_name"` // Human-readable service name for display + Reason string `json:"reason"` // Why this suggestion was generated } // DiscoveryFact represents a single discovered fact about a resource.