mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
f3d9f426c5
commit
d14bc41b66
11 changed files with 480 additions and 3 deletions
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
117
internal/servicediscovery/availability_suggestion.go
Normal file
117
internal/servicediscovery/availability_suggestion.go
Normal 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
|
||||
}
|
||||
140
internal/servicediscovery/availability_suggestion_test.go
Normal file
140
internal/servicediscovery/availability_suggestion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue