feat(discovery): suggest availability probes from discovered service types

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)
This commit is contained in:
rcourtman 2026-06-26 17:08:56 +01:00
parent f3d9f426c5
commit d14bc41b66
11 changed files with 480 additions and 3 deletions

View file

@ -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),
});
});
});

View file

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

View file

@ -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<State>('idle');
const [error, setError] = createSignal<string>('');
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 (
<InfoCardFrame data-testid="availability-probe-suggestion">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex min-w-0 items-center gap-1.5">
<Activity class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
Availability Monitoring
</h3>
</div>
<span class="shrink-0 rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted">
Suggested
</span>
</div>
<div class="space-y-1.5 text-[11px]">
<Show when={state() !== 'created'}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Service</span>
<span class="font-medium text-base-content truncate ml-2" title={props.suggestion.service_name}>
{props.suggestion.service_name}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Probe</span>
<span class="font-medium text-base-content">{protocolLabel()}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Target</span>
<span class="font-medium text-base-content truncate ml-2" title={props.suggestion.address}>
{props.suggestion.address}
</span>
</div>
<Show when={state() === 'error'}>
<div class="text-[10px] text-red-600 dark:text-red-400 mt-1">{error()}</div>
</Show>
<button
type="button"
disabled={state() === 'creating'}
onClick={handleCreate}
class="mt-2 w-full inline-flex items-center justify-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
>
<Show when={state() === 'creating'}>
<Loader2 class="h-3 w-3 animate-spin" aria-hidden="true" />
</Show>
{state() === 'creating' ? 'Creating…' : 'Monitor availability'}
</button>
</Show>
<Show when={state() === 'created'}>
<div class="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
<Check class="h-3.5 w-3.5" aria-hidden="true" />
<span class="font-medium">Probe created. Status will appear shortly.</span>
</div>
</Show>
</div>
</InfoCardFrame>
);
}

View file

@ -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}
/>
<Show when={props.discoveryIdentifiedSummary?.suggestedAvailabilityProbe && !props.guest.availability}>
<div class="mt-3 max-w-sm">
<AvailabilityProbeSuggestionCard
suggestion={props.discoveryIdentifiedSummary!.suggestedAvailabilityProbe!}
linkedResourceId={props.guest.id}
/>
</div>
</Show>
</div>
</div>
);

View file

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

View file

@ -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',
});
});

View file

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

View file

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

View file

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

View file

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

View file

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