diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 444261e80..0e1490319 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1394,6 +1394,12 @@ AI runtime. `hasValidDiscovery` — so the same record either renders in all surfaces or hides in all surfaces, preventing "Unknown" rows or zero-confidence noise from drifting into peripheral UI. + CLI access, confidence fields, and no-URL diagnostics are support + metadata; they must not by themselves promote a record into the + identified-service summary when the service name, category, version, + paths, ports, facts, and suggested URL are all absent or placeholders, + including generic workload types such as `service` or `container` and + diagnostic facts such as metadata-only status or missing-config errors. Discovery is an opt-in observed-context layer, not an automatic row-link owner. The reducer must carry provenance, observed time, service version, endpoint candidates, and URL-source copy so drawer surfaces can show @@ -2119,6 +2125,10 @@ owned title so feature drawers can place web-interface controls inside a larger access surface without forking the save/remove/runtime behavior. Future web-interface URL work should extend those owners instead of pushing metadata transport or validation back into the shared shell. +Missing-suggested-URL diagnostics remain useful only when the operator has no +saved or entered URL; once a custom web-interface URL is present, the shared +field must suppress "no suggested URL" warnings so Discovery does not make a +valid manual endpoint look broken. The shared help icon now follows that same owner split. `frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell, `frontend-modern/src/components/shared/useHelpIconState.ts` owns open state, @@ -3163,6 +3173,10 @@ surfacing "Unknown" rows or zero-confidence noise. Manual/persisted web-interface URLs still win: Discovery suggestions may be copied, opened, or adopted through the shared `WebInterfaceUrlField`, but they must not silently replace metadata or make row-name links active until the operator saves them. +No-URL diagnostics and command access hints are not endpoint candidates; they +can explain a Discovery result inside Discovery-owned surfaces, but they must +not trigger out-of-tab identified-service cards or suggested-URL panels without +another meaningful service signal. The visible provenance marker for those values is the shared `DiscoveryProvenanceMarker`; local surfaces may choose the labelled or icon-only variant, but must not invent alternate Discovery badges or hide the diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index d9b322a90..8a5fb4ac6 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -165,6 +165,11 @@ details such as detected version, config/data/log paths, Docker bind mounts, ports, and suggested web URLs may be authored in mock fixtures, but consumers must receive them through the normal Discovery API contract rather than through frontend-only demo data or a monitoring-only side channel. +Demo Discovery fixtures must cover the authored estate broadly enough that the +default drawer experience demonstrates meaningful service context instead of a +majority of unknown placeholder records. Non-HTTP services may publish a clear +no-web-interface diagnostic, but fixtures must still identify the service, +version, category, and useful operator paths through the normal Discovery API. That same monitoring boundary also owns the escalation callback bridge into the alerts delivery layer. Monitor-owned escalation handling may still publish canonical escalation state to websocket consumers, but notification fan-out diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0df1655a6..a3f76bad1 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1696,6 +1696,13 @@ That access-side analysis surface still follows the same shell/runtime split as the rest of the drawer: `DiscoveryTab.tsx` owns presentation and disclosures, while `useDiscoveryTabState.ts` owns API fetches, websocket progress, and note/discovery mutations. +Discovery validity in that runtime state must defer to the shared +`discoveryPresentation.ts` meaningful-context gate. CLI access, confidence, and +diagnostics may support an audit trail, but they must not turn placeholder +records such as unknown service/category/version with no ports, paths, facts, +or suggested URL into a valid identified result. Generic workload types such +as `service`, `container`, or `lxc` remain placeholders until Discovery also +provides a more specific service signal. Discovery endpoint candidates are support context, not authoritative resource links. Resource and workload drawers may pass observed Discovery URLs into the shared web-interface field with visible provenance, copy/open affordances, and diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx index bd00392d5..02158ad9d 100644 --- a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx @@ -39,6 +39,63 @@ describe('DiscoveryTab', () => { expect(await screen.findByRole('button', { name: 'Run Discovery Now' })).toBeInTheDocument(); }); + it('treats placeholder-only discovery records as unidentified instead of valid results', async () => { + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue({ + id: 'system-container:pve4:152', + resource_type: 'system-container', + resource_id: '152', + target_id: 'pve4', + hostname: 'smtp-relay-32', + service_type: 'unknown', + service_name: 'Unknown Container', + service_version: 'unknown', + category: 'unknown', + cli_access: 'pct exec 152 -- /bin/bash', + facts: [ + { + category: 'service', + key: 'status', + value: 'online', + source: 'metadata', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + { + category: 'config', + key: 'missing_config', + value: 'nodes/delly/lxc/152.conf not found on host', + source: 'all_commands', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + ], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0, + ai_reasoning: '', + discovered_at: '2026-05-19T00:00:00Z', + updated_at: '2026-05-19T00:00:00Z', + scan_duration: 0, + suggested_url_diagnostic: 'no host or IP candidate available', + }); + + render(() => ( + + )); + + expect(await screen.findByText('Unknown Service')).toBeInTheDocument(); + expect(screen.queryByText('Unknown Container')).toBeNull(); + }); + it('shows a drawer run action and triggers discovery for the current resource', async () => { const discovered: ResourceDiscovery = { id: 'discovery-1', diff --git a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts index 17c8bb7ff..d988b67d1 100644 --- a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts +++ b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts @@ -9,7 +9,10 @@ import { } from '@/api/discovery'; import { eventBus } from '@/stores/events'; import type { DiscoveryProgress, ResourceType } from '@/types/discovery'; -import { getDiscoveryNoConnectedAgentMessage } from '@/utils/discoveryPresentation'; +import { + getDiscoveryNoConnectedAgentMessage, + hasMeaningfulDiscoveryContext, +} from '@/utils/discoveryPresentation'; import { copyToClipboard } from '@/utils/clipboard'; import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget'; @@ -262,29 +265,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) { }); const hasValidDiscovery = createMemo(() => { - const current = discovery(); - if (!current) return false; - - const hasServiceName = current.service_name && current.service_name.toLowerCase() !== 'unknown'; - const hasConfidence = typeof current.confidence === 'number' && current.confidence > 0; - const hasPorts = current.ports && current.ports.length > 0; - const hasFacts = current.facts && current.facts.length > 0; - const hasPaths = - (current.config_paths && current.config_paths.length > 0) || - (current.data_paths && current.data_paths.length > 0) || - (current.log_paths && current.log_paths.length > 0); - const hasCliAccess = Boolean(current.cli_access); - const hasSuggestedUrl = Boolean(current.suggested_url || current.suggested_url_diagnostic); - - return Boolean( - hasServiceName || - hasConfidence || - hasPorts || - hasFacts || - hasPaths || - hasCliAccess || - hasSuggestedUrl, - ); + return hasMeaningfulDiscoveryContext(discovery()); }); const validDiscovery = createMemo(() => diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index c97ccaf77..48a03cb34 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; +import type { ResourceDiscovery } from '@/types/discovery'; import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation'; const baseResource = (): Resource => ({ id: 'host-abcd', @@ -290,3 +292,52 @@ describe('toDiscoveryConfig', () => { }); }); }); + +describe('resource drawer discovery promotion', () => { + it('does not treat command-only diagnostic records as meaningful resource context', () => { + const discovery: ResourceDiscovery = { + id: 'system-container:pve4:152', + resource_type: 'system-container', + resource_id: '152', + target_id: 'pve4', + hostname: 'smtp-relay-32', + service_type: 'unknown', + service_name: 'Unknown Container', + service_version: 'unknown', + category: 'unknown', + cli_access: 'pct exec 152 -- /bin/bash', + facts: [ + { + category: 'service', + key: 'status', + value: 'online', + source: 'metadata', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + { + category: 'config', + key: 'missing_config', + value: 'nodes/delly/lxc/152.conf not found on host', + source: 'all_commands', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + ], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0, + ai_reasoning: 'Discovery commands could not inspect the workload.', + discovered_at: '2026-05-19T00:00:00Z', + updated_at: '2026-05-19T00:00:00Z', + scan_duration: 2816, + suggested_url_diagnostic: 'no host or IP candidate available', + }; + + expect(hasMeaningfulDiscoveryContext(discovery)).toBe(false); + }); +}); diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 6c1aa5c92..129c74e12 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -243,6 +243,53 @@ describe('GuestDrawer', () => { expect(screen.queryByText('Identified Service')).toBeNull(); }); + it('hides low-signal placeholder discovery from the Overview card and URL field', async () => { + discoveryApiMocks.getDiscovery.mockResolvedValueOnce({ + id: 'system-container:pve4:152', + resource_type: 'system-container', + resource_id: '152', + target_id: 'pve4', + service_name: '', + service_type: 'service', + service_version: '', + category: 'unknown', + confidence: 0, + cli_access: 'pct exec 152 -- /bin/bash', + ports: [], + facts: [ + { + category: 'service', + key: 'status', + value: 'online', + source: 'metadata', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + { + category: 'config', + key: 'missing_config', + value: 'nodes/delly/lxc/152.conf not found on host', + source: 'all_commands', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + ], + config_paths: [], + data_paths: [], + log_paths: [], + updated_at: '2026-05-18T09:49:19.049058+01:00', + suggested_url_diagnostic: 'no host or IP candidate available', + } as unknown as import('@/types/discovery').ResourceDiscovery); + + render(() => ); + + await waitFor(() => { + expect(discoveryApiMocks.getDiscovery).toHaveBeenCalled(); + }); + expect(screen.queryByText('Identified Service')).toBeNull(); + expect(screen.getByTestId('url-suggested-diagnostic')).toHaveTextContent(''); + }); + it('keeps the passive discovery lookup out of the parent Suspense fallback', () => { discoveryApiMocks.getDiscovery.mockImplementationOnce( () => new Promise(() => undefined), diff --git a/frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx b/frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx index b266f60c3..7c24980f5 100644 --- a/frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx @@ -123,6 +123,20 @@ describe('WebInterfaceUrlField', () => { expect(screen.getByLabelText(getDiscoveryProvenanceTitle())).toBeInTheDocument(); }); + it('does not show missing suggested URL diagnostics when a custom URL already exists', async () => { + render(() => ( + + )); + + expect(await screen.findByDisplayValue('https://198.51.100.100:8080')).toBeInTheDocument(); + expect(screen.queryByText('No suggested URL available')).toBeNull(); + }); + it('offers discovered URL copy, open, and adopt actions without saving automatically', async () => { render(() => ( normalizeWebInterfaceUrl(props.suggestedUrl)); const showSuggestedDiagnostic = createMemo(() => shouldShowWebInterfaceSuggestedDiagnostic({ + currentUrl: currentCustomUrl(), discoveryLoading: props.discoveryLoading, suggestedUrl: props.suggestedUrl, suggestedUrlDiagnostic: props.suggestedUrlDiagnostic, diff --git a/frontend-modern/src/components/shared/webInterfaceUrlFieldModel.ts b/frontend-modern/src/components/shared/webInterfaceUrlFieldModel.ts index 020f2ea80..bb54c2ee6 100644 --- a/frontend-modern/src/components/shared/webInterfaceUrlFieldModel.ts +++ b/frontend-modern/src/components/shared/webInterfaceUrlFieldModel.ts @@ -52,11 +52,13 @@ export function getWebInterfaceTargetLabel( export function shouldShowWebInterfaceSuggestedDiagnostic(options: { discoveryLoading?: boolean; + currentUrl?: string; suggestedUrl?: string; suggestedUrlDiagnostic?: string; }): boolean { return ( !options.discoveryLoading && + !normalizeWebInterfaceUrl(options.currentUrl) && !normalizeWebInterfaceUrl(options.suggestedUrl) && Boolean(options.suggestedUrlDiagnostic) ); diff --git a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts index 61a416499..4f73b0af9 100644 --- a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts @@ -235,7 +235,24 @@ describe('discoveryPresentation', () => { service_name: 'Unknown', confidence: 0, ports: [], - facts: [], + facts: [ + { + category: 'service', + key: 'status', + value: 'online', + source: 'metadata', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + { + category: 'config', + key: 'missing_config', + value: 'nodes/delly/lxc/152.conf not found on host', + source: 'all_commands', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + ], config_paths: [], data_paths: [], log_paths: [], @@ -261,11 +278,60 @@ describe('discoveryPresentation', () => { service_name: 'Unknown', confidence: 0, ports: [], - facts: [], + facts: [ + { + category: 'service', + key: 'status', + value: 'online', + source: 'metadata', + confidence: 1, + discovered_at: '2026-05-19T00:00:00Z', + }, + ], config_paths: [], data_paths: [], log_paths: [], } as unknown as ResourceDiscovery), ).toBeNull(); + expect( + getDiscoveryIdentifiedSummary({ + id: 'system-container:pve4:152', + resource_type: 'system-container', + resource_id: '152', + target_id: 'pve4', + service_name: 'Unknown Container', + service_type: 'unknown', + service_version: 'unknown', + category: 'unknown', + confidence: 0, + cli_access: 'pct exec 152 -- /bin/bash', + ports: [], + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + suggested_url_diagnostic: 'no host or IP candidate available', + } as unknown as ResourceDiscovery), + ).toBeNull(); + expect( + getDiscoveryIdentifiedSummary({ + id: 'system-container:pve4:152', + resource_type: 'system-container', + resource_id: '152', + target_id: 'pve4', + service_name: '', + service_type: 'service', + service_version: '', + category: 'unknown', + confidence: 0, + cli_access: 'pct exec 152 -- /bin/bash', + ports: [], + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + suggested_url_diagnostic: 'no host or IP candidate available', + } as unknown as ResourceDiscovery), + ).toBeNull(); }); }); diff --git a/frontend-modern/src/utils/discoveryPresentation.ts b/frontend-modern/src/utils/discoveryPresentation.ts index abe99ee22..b12251889 100644 --- a/frontend-modern/src/utils/discoveryPresentation.ts +++ b/frontend-modern/src/utils/discoveryPresentation.ts @@ -1,4 +1,4 @@ -import type { ResourceDiscovery } from '@/types/discovery'; +import type { DiscoveryFact, ResourceDiscovery } from '@/types/discovery'; import { getInfrastructureSettingsLocationLabel, getInfrastructureSettingsTarget, @@ -38,6 +38,87 @@ const toSentence = (text?: string | null): string => { return first.toUpperCase() + trimmed.slice(1); }; +const normalizeDiscoveryToken = (value?: string | null): string => { + return (value || '').trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' '); +}; + +const isMeaningfulDiscoveryText = (value?: string | null): boolean => { + const normalized = normalizeDiscoveryToken(value); + if (!normalized) return false; + return ![ + 'detected', + 'n a', + 'none', + 'app', + 'application', + 'container', + 'host', + 'linux', + 'lxc', + 'service', + 'system container', + 'unknown', + 'unknown app', + 'unknown application', + 'unknown container', + 'unknown host', + 'unknown service', + 'unknown system container', + 'unknown virtual machine', + 'unknown vm', + 'unknown workload', + 'virtual machine', + 'vm', + 'workload', + ].includes(normalized); +}; + +const isMeaningfulDiscoveryFact = (fact: DiscoveryFact): boolean => { + const key = normalizeDiscoveryToken(fact.key); + const value = normalizeDiscoveryToken(fact.value); + const source = normalizeDiscoveryToken(fact.source); + if (!isMeaningfulDiscoveryText(fact.value)) return false; + if (key === 'status' && source === 'metadata') return false; + if (key.startsWith('missing') || key.endsWith('missing')) return false; + if ( + value.includes('does not exist') || + value.includes('not found') || + value.includes('failed') || + value.includes('error') + ) { + return false; + } + return ['config', 'dependency', 'network', 'port', 'security', 'service', 'version'].includes( + normalizeDiscoveryToken(fact.category), + ); +}; + +export function hasMeaningfulDiscoveryContext( + discovery: ResourceDiscovery | null | undefined, +): boolean { + if (!discovery) return false; + const portCount = Array.isArray(discovery.ports) ? discovery.ports.length : 0; + const configPathCount = Array.isArray(discovery.config_paths) ? discovery.config_paths.length : 0; + const dataPathCount = Array.isArray(discovery.data_paths) ? discovery.data_paths.length : 0; + const logPathCount = Array.isArray(discovery.log_paths) ? discovery.log_paths.length : 0; + const hasFacts = + Array.isArray(discovery.facts) && + discovery.facts.some((fact) => isMeaningfulDiscoveryFact(fact)); + const hasPaths = configPathCount + dataPathCount + logPathCount > 0; + const hasSuggestedUrl = Boolean(normalizeDiscoverySuggestedUrl(discovery.suggested_url)); + + return Boolean( + isMeaningfulDiscoveryText(discovery.service_name) || + isMeaningfulDiscoveryText(discovery.service_type) || + isMeaningfulDiscoveryText(discovery.service_version) || + isMeaningfulDiscoveryText(discovery.category) || + portCount > 0 || + hasFacts || + hasPaths || + hasSuggestedUrl, + ); +} + // getDiscoveryIdentifiedSummary returns a compact presentation object for // surfaces outside the Discovery sub-tab (e.g. the workload drawer overview) // to label a resource with its identified service and endpoint candidates. @@ -48,39 +129,28 @@ export function getDiscoveryIdentifiedSummary( discovery: ResourceDiscovery | null | undefined, ): DiscoveryIdentifiedSummary | null { if (!discovery) return null; + if (!hasMeaningfulDiscoveryContext(discovery)) return null; const serviceName = (discovery.service_name || '').trim(); - const hasName = serviceName.length > 0 && serviceName.toLowerCase() !== 'unknown'; + const hasName = isMeaningfulDiscoveryText(serviceName); const confidence = typeof discovery.confidence === 'number' ? discovery.confidence : 0; - const hasConfidence = confidence > 0; const portCount = Array.isArray(discovery.ports) ? discovery.ports.length : 0; const configPathCount = Array.isArray(discovery.config_paths) ? discovery.config_paths.length : 0; const dataPathCount = Array.isArray(discovery.data_paths) ? discovery.data_paths.length : 0; const logPathCount = Array.isArray(discovery.log_paths) ? discovery.log_paths.length : 0; - const hasFacts = Array.isArray(discovery.facts) && discovery.facts.length > 0; const hasCli = typeof discovery.cli_access === 'string' && discovery.cli_access.trim().length > 0; - const hasPaths = configPathCount + dataPathCount + logPathCount > 0; const suggestedUrl = normalizeDiscoverySuggestedUrl(discovery.suggested_url); - const hasEndpointCandidate = - Boolean(suggestedUrl) || - (typeof discovery.suggested_url_diagnostic === 'string' && - discovery.suggested_url_diagnostic.trim().length > 0); - if ( - !hasName && - !hasConfidence && - portCount === 0 && - !hasFacts && - !hasPaths && - !hasCli && - !hasEndpointCandidate - ) { - return null; - } const suggestedUrlReason = getDiscoverySuggestedURLReason(discovery); return { serviceName: hasName ? serviceName : 'Unidentified service', - serviceType: discovery.service_type?.trim() || undefined, - serviceVersion: discovery.service_version?.trim() || undefined, - category: discovery.category?.trim() || undefined, + serviceType: isMeaningfulDiscoveryText(discovery.service_type) + ? discovery.service_type?.trim() + : undefined, + serviceVersion: isMeaningfulDiscoveryText(discovery.service_version) + ? discovery.service_version?.trim() + : undefined, + category: isMeaningfulDiscoveryText(discovery.category) + ? discovery.category?.trim() + : undefined, confidence, confidencePercent: `${Math.round(confidence * 100)}%`, cliAccess: hasCli ? discovery.cli_access?.trim() : undefined, @@ -95,7 +165,7 @@ export function getDiscoveryIdentifiedSummary( suggestedUrlReasonText: suggestedUrlReason.text || undefined, suggestedUrlReasonTitle: suggestedUrlReason.title || undefined, suggestedUrlDiagnostic: discovery.suggested_url_diagnostic?.trim() || undefined, - hasEndpointCandidate, + hasEndpointCandidate: Boolean(suggestedUrl), }; } diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index 2c65e4ec6..27d3fc5dd 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -91,6 +91,7 @@ func TestBuildFixtureGraphIncludesDiscoveryContextFixtures(t *testing.T) { var hasDockerURL bool var hasVM bool var hasAgent bool + var hasSMTPRelay bool for _, discovery := range graph.DiscoveryFixtures { if discovery == nil { t.Fatal("discovery fixture must not be nil") @@ -101,6 +102,9 @@ func TestBuildFixtureGraphIncludesDiscoveryContextFixtures(t *testing.T) { if discovery.ServiceName == "" || discovery.ServiceVersion == "" || len(discovery.ConfigPaths) == 0 { t.Fatalf("discovery fixture missing operator context: %+v", discovery) } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(discovery.ServiceName)), "unknown") { + t.Fatalf("mock discovery fixture must not publish placeholder service names: %+v", discovery) + } switch discovery.ResourceType { case discoveryResourceTypeDocker: hasDockerURL = hasDockerURL || discovery.SuggestedURL != "" && len(discovery.DockerMounts) > 0 @@ -108,6 +112,8 @@ func TestBuildFixtureGraphIncludesDiscoveryContextFixtures(t *testing.T) { hasVM = true case discoveryResourceTypeAgent: hasAgent = true + case discoveryResourceTypeSystemContainer: + hasSMTPRelay = hasSMTPRelay || strings.Contains(discovery.ServiceName, "SMTP") } } if !hasDockerURL { @@ -116,6 +122,9 @@ func TestBuildFixtureGraphIncludesDiscoveryContextFixtures(t *testing.T) { if !hasVM || !hasAgent { t.Fatalf("expected guest and host discovery fixtures, hasVM=%t hasAgent=%t", hasVM, hasAgent) } + if !hasSMTPRelay { + t.Fatal("expected system-container discovery fixtures to identify SMTP relay workloads") + } } func TestDemoScenarioStorageNamingHandlesScaledNodeCount(t *testing.T) { diff --git a/internal/mock/discovery_fixtures.go b/internal/mock/discovery_fixtures.go index f2875e6b0..9cbcfe5c3 100644 --- a/internal/mock/discovery_fixtures.go +++ b/internal/mock/discovery_fixtures.go @@ -11,11 +11,11 @@ import ( ) const ( - maxMockVMDiscoveryFixtures = 4 - maxMockContainerDiscoveryFixtures = 4 - maxMockDockerDiscoveryFixtures = 10 - maxMockHostDiscoveryFixtures = 4 - maxMockK8sDiscoveryFixtures = 4 + maxMockVMDiscoveryFixtures = 48 + maxMockContainerDiscoveryFixtures = 64 + maxMockDockerDiscoveryFixtures = 96 + maxMockHostDiscoveryFixtures = 6 + maxMockK8sDiscoveryFixtures = 80 discoveryResourceTypeVM = "vm" discoveryResourceTypeSystemContainer = "system-container" @@ -498,6 +498,16 @@ func mockServiceDiscoveryProfile(name, image, runtime string) mockDiscoveryProfi return mockProfile("redis", "Redis", firstNonEmpty(version, "7.2"), discoveryCategoryCache, 6379, "redis-server", "", "", "No web interface was suggested because Redis exposes a cache port, not an HTTP UI.", []string{"/usr/local/etc/redis/redis.conf", "/etc/redis/redis.conf"}, []string{"/data"}, []string{"/var/log/redis/redis-server.log"}) case strings.Contains(token, "traefik") || strings.Contains(token, "edge-proxy"): return mockProfile("traefik", "Traefik", firstNonEmpty(version, "3.1"), discoveryCategoryWebServer, 443, "traefik", "https", "/", "", []string{"/etc/traefik/traefik.yml", "/etc/traefik/dynamic"}, []string{"/letsencrypt"}, []string{"/var/log/traefik/traefik.log"}) + case strings.Contains(token, "smtp") || strings.Contains(token, "postfix") || strings.Contains(token, "mail-relay"): + return mockProfile("postfix", "Postfix SMTP Relay", firstNonEmpty(version, "3.8"), discoveryCategoryNetwork, 25, "master", "", "", "No web interface was suggested because the detected service exposes SMTP, not an HTTP UI.", []string{"/etc/postfix/main.cf", "/etc/postfix/master.cf"}, []string{"/var/spool/postfix"}, []string{"/var/log/mail.log"}) + case strings.Contains(token, "auth-service"): + return mockProfile("auth-service", "Auth Service", firstNonEmpty(version, "2026.04"), discoveryCategorySecurity, 8080, "auth-service", "http", "/", "", []string{"/etc/auth-service/config.yaml"}, []string{"/var/lib/auth-service"}, []string{"/var/log/auth-service.log"}) + case strings.Contains(token, "billing-worker") || strings.Contains(token, "payments-worker"): + return mockProfile("queue-worker", "Queue Worker", firstNonEmpty(version, "2026.04"), discoveryCategoryBackup, 0, "queue-worker", "", "", "No web interface was suggested because this workload is a background worker.", []string{"/etc/pulse-demo/worker.yaml"}, []string{"/var/lib/pulse-demo/worker"}, []string{"/var/log/pulse-demo/worker.log"}) + case strings.Contains(token, "reporting-api") || strings.Contains(token, "inventory-api") || strings.Contains(token, "checkout-api"): + return mockProfile("api-service", "API Service", firstNonEmpty(version, "2026.04"), discoveryCategoryWebServer, 8080, "api-service", "http", "/", "", []string{"/etc/pulse-demo/api.yaml"}, []string{"/var/lib/pulse-demo/api"}, []string{"/var/log/pulse-demo/api.log"}) + case strings.Contains(token, "docs-wiki") || strings.Contains(token, "docs-portal") || strings.Contains(token, "dev-portal") || strings.Contains(token, "customer-portal"): + return mockProfile("web-portal", "Web Portal", firstNonEmpty(version, "2026.04"), discoveryCategoryWebServer, 8080, "web-portal", "http", "/", "", []string{"/etc/pulse-demo/portal.yaml"}, []string{"/var/lib/pulse-demo/portal"}, []string{"/var/log/pulse-demo/portal.log"}) case strings.Contains(token, "vaultwarden"): return mockProfile("vaultwarden", "Vaultwarden", firstNonEmpty(version, "1.32.7"), discoveryCategorySecurity, 80, "vaultwarden", "http", "/", "", []string{"/data/config.json"}, []string{"/data"}, []string{"/data/vaultwarden.log"}) case strings.Contains(token, "uptime-kuma"):