Add Discovery provenance markers

This commit is contained in:
rcourtman 2026-05-20 13:32:34 +01:00
parent ba896a5366
commit f83d25d155
13 changed files with 142 additions and 26 deletions

View file

@ -1400,6 +1400,11 @@ AI runtime.
"Observed by Discovery" context and pass suggested URLs into the shared
web-interface field. Persisted/manual web-interface metadata remains the
only row-link source until the operator explicitly adopts a suggested URL.
Discovery-sourced values rendered outside the Discovery tab must carry the
shared compact provenance marker from
`frontend-modern/src/components/shared/DiscoveryProvenanceMarker.tsx`, so
operators can distinguish opt-in Discovery context from API-owned resource
facts without reading a drawer-specific explanation.
## Current State
@ -3158,6 +3163,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.
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
source on compact cards.
That same settings-shell boundary now also owns the shared settings
presentation helpers that those panels consume. `frontend-modern/src/utils/systemSettingsPresentation.ts`
is the canonical owner for shared system-settings presets, summaries, and

View file

@ -1581,3 +1581,7 @@ dependency, row-time endpoint probe, or auto-save mutation. Suggested
URLs remain copy/open/adopt affordances only; persisting them as a
workload link stays a user action through the existing Web Interface URL
field.
The compact provenance marker for those values is presentation-only. It
may identify already-loaded Discovery fields, but must not introduce
additional discovery fetches, provider lookups, endpoint probes, or
per-row layout measurement on the Workloads hot path.

View file

@ -1696,6 +1696,10 @@ continue to use the operator-saved web-interface URL. Version, config-path,
port, and endpoint facts surfaced outside the Discovery sub-tab must be
labelled as Discovery-observed so API-owned resource facts and command-derived
facts remain distinguishable.
That label must be visible through the shared Discovery provenance marker on
compact cards, suggested URL panels, and other out-of-tab Discovery values
rather than buried in helper text or inferred from the tab where the operator
found the value.
Command-availability guidance inside that analysis surface must consume the
shared `frontend-modern/src/utils/discoveryPresentation.ts` settings targets
and scan-error copy instead of hard-coding legacy settings labels such as

View file

@ -25,6 +25,7 @@ import {
getDiscoverySuggestedURLHeadingClass,
getDiscoverySuggestedURLTextClass,
} from '@/utils/discoveryPresentation';
import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker';
import {
DISCOVERY_ANALYSIS_EXPLANATION,
DISCOVERY_ANALYSIS_REASONING_LABEL,
@ -661,11 +662,15 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="rounded border border-border bg-surface p-3 shadow-sm">
<div class="flex items-start justify-between">
<div>
<h3 class="text-sm font-semibold text-base-content">
{d().service_name || 'Unknown Service'}
</h3>
<div class="flex flex-wrap items-center gap-1.5">
<h3 class="text-sm font-semibold text-base-content">
{d().service_name || 'Unknown Service'}
</h3>
<DiscoveryProvenanceMarker />
</div>
<Show when={d().service_version}>
<div class="mt-1 flex items-center gap-2">
<DiscoveryProvenanceMarker showLabel={false} />
<span class="text-xs text-muted">Version {d().service_version}</span>
<CopyValueButton
value={d().service_version}
@ -690,9 +695,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</p>
</Show>
<div class="mt-3 flex flex-wrap gap-2 text-[10px] text-muted">
<span class="rounded border border-border bg-surface-alt px-2 py-0.5">
{getDiscoveryObservedSourceLabel()}
</span>
<DiscoveryProvenanceMarker label={getDiscoveryObservedSourceLabel()} />
<Show when={d().updated_at}>
<span class="rounded border border-border bg-surface-alt px-2 py-0.5">
Last observed {formatDiscoveryAge(d().updated_at)}
@ -706,7 +709,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<Show when={d().suggested_url || d().suggested_url_diagnostic}>
<div class={getDiscoverySuggestedURLCardClass()}>
<div class={getDiscoverySuggestedURLHeadingClass()}>Web Interface Suggestion</div>
<div class={`${getDiscoverySuggestedURLHeadingClass()} flex items-center gap-1.5`}>
<span>Web Interface Suggestion</span>
<DiscoveryProvenanceMarker />
</div>
<Show
when={d().suggested_url}
fallback={
@ -756,8 +762,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{/* CLI Access */}
<Show when={d().cli_access}>
<div class="rounded border border-border p-3 shadow-sm">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
CLI Access
<div class="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>CLI Access</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<CopyableCodeRow
value={d().cli_access}
@ -779,8 +786,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="rounded border border-border p-3 shadow-sm">
<Show when={d().config_paths?.length > 0}>
<div class="mb-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-1">
Config Paths
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>Config Paths</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<div class="space-y-1">
<For each={d().config_paths}>
@ -798,8 +806,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</Show>
<Show when={d().data_paths?.length > 0}>
<div class="mb-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-1">
Data Paths
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>Data Paths</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<div class="space-y-1">
<For each={d().data_paths}>
@ -817,8 +826,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</Show>
<Show when={d().log_paths?.length > 0}>
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-1">
Log Paths
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>Log Paths</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<div class="space-y-1">
<For each={d().log_paths}>
@ -840,8 +850,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{/* Ports */}
<Show when={d().ports?.length > 0}>
<div class="rounded border border-border p-3 shadow-sm">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Listening Ports
<div class="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>Listening Ports</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<div class="flex flex-wrap gap-1">
<For each={d().ports}>
@ -877,8 +888,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{/* Key Facts */}
<Show when={d().facts?.length > 0}>
<div class="rounded border border-border p-3 shadow-sm">
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Discovered Facts
<div class="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-base-content">
<span>Discovered Facts</span>
<DiscoveryProvenanceMarker showLabel={false} />
</div>
<div class="space-y-1.5">
<For each={d().facts.slice(0, 8)}>

View file

@ -23,6 +23,7 @@ vi.mock('@/utils/clipboard', () => ({
import * as discoveryApi from '@/api/discovery';
import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab';
import { copyToClipboard } from '@/utils/clipboard';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
describe('DiscoveryTab', () => {
afterEach(() => {
@ -176,6 +177,7 @@ describe('DiscoveryTab', () => {
expect(await screen.findByText('Analysis: Cloud (Anthropic)')).toBeInTheDocument();
expect(await screen.findByText('Observed by Discovery')).toBeInTheDocument();
expect(await screen.findByText('Available to Pulse Assistant')).toBeInTheDocument();
expect(screen.getAllByLabelText(getDiscoveryProvenanceTitle()).length).toBeGreaterThan(1);
expect(await screen.findByText('Analysis Reasoning')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open suggested URL' })).toHaveAttribute(
'href',

View file

@ -5,6 +5,7 @@ import type { WorkloadGuest } from '@/types/workloads';
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { getCanonicalWorkloadId } from '@/utils/workloads';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
// ── Mocks ──────────────────────────────────────────────────────────────
@ -226,6 +227,7 @@ describe('GuestDrawer', () => {
expect(screen.getByText('web_server')).toBeInTheDocument();
expect(screen.getByText('0.9.0')).toBeInTheDocument();
expect(screen.getByText('95%')).toBeInTheDocument();
expect(screen.getByLabelText(getDiscoveryProvenanceTitle())).toBeInTheDocument();
expect(screen.getAllByText('http://192.0.2.10:3000').length).toBeGreaterThan(0);
expect(screen.getByText('docker exec -it homepage /bin/sh')).toBeInTheDocument();
expect(screen.getByTestId('url-suggested')).toHaveTextContent('http://192.0.2.10:3000');

View file

@ -1,6 +1,7 @@
import { For, Show } from 'solid-js';
import { formatDiscoveryAge } from '@/api/discovery';
import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import { formatBytes, formatUptime } from '@/utils/format';
@ -45,10 +46,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
{(summary) => (
<div class="rounded border border-border bg-surface p-3 shadow-sm">
<div class="flex items-center justify-between gap-2 mb-2">
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content">
Identified Service
</h3>
<span class="text-[10px] font-medium text-muted">
<div class="flex min-w-0 items-center gap-1.5">
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
Identified Service
</h3>
<DiscoveryProvenanceMarker />
</div>
<span class="shrink-0 text-[10px] font-medium text-muted">
{summary().confidencePercent}
</span>
</div>

View file

@ -0,0 +1,37 @@
import { Show, type Component } from 'solid-js';
import ScanSearchIcon from 'lucide-solid/icons/scan-search';
import {
getDiscoveryProvenanceBadgeClass,
getDiscoveryProvenanceIconClass,
getDiscoveryProvenanceLabel,
getDiscoveryProvenanceTitle,
} from '@/utils/discoveryPresentation';
interface DiscoveryProvenanceMarkerProps {
showLabel?: boolean;
label?: string;
title?: string;
class?: string;
testId?: string;
}
export const DiscoveryProvenanceMarker: Component<DiscoveryProvenanceMarkerProps> = (props) => {
const showLabel = () => props.showLabel !== false;
const title = () => props.title || getDiscoveryProvenanceTitle();
const label = () => props.label || getDiscoveryProvenanceLabel();
const className = () =>
props.class ||
(showLabel() ? getDiscoveryProvenanceBadgeClass() : getDiscoveryProvenanceIconClass());
return (
<span class={className()} title={title()} aria-label={title()} data-testid={props.testId}>
<ScanSearchIcon class="h-3 w-3" aria-hidden="true" />
<Show when={showLabel()}>
<span>{label()}</span>
</Show>
</span>
);
};
export default DiscoveryProvenanceMarker;

View file

@ -2,6 +2,7 @@ import { Component, Show } from 'solid-js';
import CheckIcon from 'lucide-solid/icons/check';
import CopyIcon from 'lucide-solid/icons/copy';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import { DiscoveryProvenanceMarker } from './DiscoveryProvenanceMarker';
import { useWebInterfaceUrlFieldState } from './useWebInterfaceUrlFieldState';
import type { WebInterfaceUrlFieldProps } from './webInterfaceUrlFieldModel';
@ -105,15 +106,19 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
<Show when={state.showSuggestedDiagnostic()}>
<div class="mt-2 rounded border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-800 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-200">
<p class="font-medium">{state.suggestedUrlFallback().title}</p>
<div class="flex items-center gap-1.5 font-medium">
<span>{state.suggestedUrlFallback().title}</span>
<DiscoveryProvenanceMarker />
</div>
<p class="mt-0.5">{state.suggestedUrlFallback().description}</p>
</div>
</Show>
<Show when={state.showSuggestedUrl()}>
<div class="mt-2 p-2 rounded bg-blue-50 border border-blue-200 dark:bg-blue-900 dark:border-blue-800">
<div class="text-[10px] font-medium text-blue-700 dark:text-blue-300 mb-1">
{state.normalizedCurrentUrl() ? 'Discovered URL' : 'Suggested URL'}
<div class="mb-1 flex items-center gap-1.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
<span>{state.normalizedCurrentUrl() ? 'Discovered URL' : 'Suggested URL'}</span>
<DiscoveryProvenanceMarker />
</div>
<Show when={props.suggestedUrlReasonText}>
<p

View file

@ -25,6 +25,7 @@ vi.mock('@/utils/clipboard', () => ({
import { AgentMetadataAPI } from '@/api/agentMetadata';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
import { copyToClipboard } from '@/utils/clipboard';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
describe('WebInterfaceUrlField', () => {
afterEach(() => {
@ -119,6 +120,7 @@ describe('WebInterfaceUrlField', () => {
expect(await screen.findByText('No suggested URL available')).toBeInTheDocument();
expect(screen.getByText('No management interface could be inferred.')).toBeInTheDocument();
expect(screen.getByLabelText(getDiscoveryProvenanceTitle())).toBeInTheDocument();
});
it('offers discovered URL copy, open, and adopt actions without saving automatically', async () => {
@ -133,6 +135,7 @@ describe('WebInterfaceUrlField', () => {
));
expect(await screen.findByText('Suggested URL')).toBeInTheDocument();
expect(screen.getByLabelText(getDiscoveryProvenanceTitle())).toBeInTheDocument();
expect(screen.getByText('http://192.0.2.10:8123')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open suggested URL' })).toHaveAttribute(
'href',

View file

@ -6,6 +6,10 @@ import {
getDiscoveryInitialEmptyState,
getDiscoveryLoadingState,
getDiscoveryNoConnectedAgentMessage,
getDiscoveryProvenanceBadgeClass,
getDiscoveryProvenanceIconClass,
getDiscoveryProvenanceLabel,
getDiscoveryProvenanceTitle,
getNetworkDiscoveryModePresentation,
getDiscoveryNotesEmptyState,
getDiscoveryObservedSourceLabel,
@ -60,6 +64,10 @@ describe('discoveryPresentation', () => {
expect(getDiscoveryCategoryBadgeClass()).toContain('text-blue-700');
expect(getDiscoverySuggestedURLCardClass()).toContain('bg-blue-50');
expect(getDiscoverySuggestedURLActionClass()).toContain('text-blue-700');
expect(getDiscoveryProvenanceLabel()).toBe('Discovery');
expect(getDiscoveryProvenanceTitle()).toContain('opt-in Discovery');
expect(getDiscoveryProvenanceBadgeClass()).toContain('border-cyan-200');
expect(getDiscoveryProvenanceIconClass()).toContain('h-4');
});
it('returns canonical discovery empty-state copy', () => {

View file

@ -329,6 +329,7 @@ import useUnifiedResourcesSource from '@/hooks/useUnifiedResources.ts?raw';
import useWorkloadsSource from '@/hooks/useWorkloads.ts?raw';
import findingsPanelSource from '@/components/AI/FindingsPanel.tsx?raw';
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
import discoveryProvenanceMarkerSource from '@/components/shared/DiscoveryProvenanceMarker.tsx?raw';
import discoveryPresentationSource from '@/utils/discoveryPresentation.ts?raw';
import mailGatewaySource from '@/components/PMG/MailGateway.tsx?raw';
import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw';
@ -3170,6 +3171,12 @@ describe('frontend resource type boundaries', () => {
expect(discoveryPresentationSource).toContain(
'export function getDiscoveryObservedSourceLabel',
);
expect(discoveryPresentationSource).toContain('export function getDiscoveryProvenanceTitle');
expect(discoveryProvenanceMarkerSource).toContain('getDiscoveryProvenanceTitle');
expect(discoveryProvenanceMarkerSource).toContain('getDiscoveryProvenanceBadgeClass');
expect(discoveryTabSource).toContain('DiscoveryProvenanceMarker');
expect(webInterfaceUrlFieldSource).toContain('DiscoveryProvenanceMarker');
expect(guestDrawerOverviewSource).toContain('DiscoveryProvenanceMarker');
expect(discoveryPresentationSource).toContain(
'export function getDiscoverySuggestedURLCardClass',
);

View file

@ -27,6 +27,9 @@ export interface DiscoveryIdentifiedSummary {
}
const OBSERVED_SOURCE_LABEL = 'Observed by Discovery';
const DISCOVERY_PROVENANCE_LABEL = 'Discovery';
const DISCOVERY_PROVENANCE_TITLE =
'Surfaced by opt-in Discovery from agent-observed workload context.';
const toSentence = (text?: string | null): string => {
const trimmed = (text || '').trim();
@ -100,6 +103,22 @@ export function getDiscoveryObservedSourceLabel(): string {
return OBSERVED_SOURCE_LABEL;
}
export function getDiscoveryProvenanceLabel(): string {
return DISCOVERY_PROVENANCE_LABEL;
}
export function getDiscoveryProvenanceTitle(): string {
return DISCOVERY_PROVENANCE_TITLE;
}
export function getDiscoveryProvenanceBadgeClass(): string {
return 'inline-flex h-5 shrink-0 items-center gap-1 rounded border border-cyan-200 bg-cyan-50 px-1.5 text-[10px] font-medium leading-none text-cyan-700 dark:border-cyan-800 dark:bg-cyan-950 dark:text-cyan-200';
}
export function getDiscoveryProvenanceIconClass(): string {
return 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-cyan-200 bg-cyan-50 text-cyan-700 dark:border-cyan-800 dark:bg-cyan-950 dark:text-cyan-200';
}
export function normalizeDiscoverySuggestedUrl(value?: string | null): string | undefined {
const trimmed = (value || '').trim();
return trimmed || undefined;