Tighten Discovery drawer signal

This commit is contained in:
rcourtman 2026-05-20 15:55:19 +01:00
parent 59417a7da7
commit ba374da2ee
14 changed files with 389 additions and 55 deletions

View file

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

View file

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

View file

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

View file

@ -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(() => (
<DiscoveryTab
resourceType="system-container"
agentId="pve4"
resourceId="152"
hostname="smtp-relay-32"
/>
));
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',

View file

@ -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(() =>

View file

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

View file

@ -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(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
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<import('@/types/discovery').ResourceDiscovery | null>(() => undefined),

View file

@ -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(() => (
<WebInterfaceUrlField
metadataKind="guest"
metadataId="guest-1"
customUrl="https://198.51.100.100:8080"
suggestedUrlDiagnostic="No management interface could be inferred."
/>
));
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(() => (
<WebInterfaceUrlField

View file

@ -31,6 +31,7 @@ export function useWebInterfaceUrlFieldState(props: WebInterfaceUrlFieldProps) {
const normalizedSuggestedUrl = createMemo(() => normalizeWebInterfaceUrl(props.suggestedUrl));
const showSuggestedDiagnostic = createMemo(() =>
shouldShowWebInterfaceSuggestedDiagnostic({
currentUrl: currentCustomUrl(),
discoveryLoading: props.discoveryLoading,
suggestedUrl: props.suggestedUrl,
suggestedUrlDiagnostic: props.suggestedUrlDiagnostic,

View file

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

View file

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

View file

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

View file

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

View file

@ -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"):