Unify Discovery readiness onto a single verdict

Discovery re-derived its prerequisite state (disabled / provider /
commands / connectivity) ad hoc across surfaces, so the agent
connection-status banners could nag about commands while the more
fundamental gap — no AI provider configured — went unsaid.

Wire the (previously orphaned, unwired) computeDiscoveryReadiness module
in as the canonical verdict: useDiscoveryTabState now derives
aiProviderConfigured + a single discoveryReadiness memo, and the
DiscoveryTab connection-status block renders from readiness().status,
surfacing the most-fundamental missing prerequisite first. Adds the
missing needs_ai_provider banner. The green 'connected' banner stays
literal by design (the verdict treats unknown command state as ready).

Tests: existing command-state cases now configure a provider to reach
those states; new case asserts the provider prerequisite precedes
command guidance. Full frontend suite + type-check + lint green (the 2
unrelated guardrail failures pre-exist on the branch, proven via scoped
stash).
This commit is contained in:
rcourtman
2026-06-07 11:25:19 +01:00
parent 93420de157
commit c2c5ce0488
5 changed files with 198 additions and 4 deletions
@@ -99,6 +99,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
copiedDiscoveryValue,
discovery,
discoveryFeatureKnownDisabled,
discoveryReadiness,
discoveryInfo,
editingNotes,
handleCopyDiscoveryValue,
@@ -516,9 +517,45 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</Show>
</div>
{/* Connection Status Warning - Show when commands are needed but not available */}
<Show when={props.resourceType === 'agent' && !connectedAgents.loading}>
<Show when={props.commandsEnabled === false}>
{/* Connection Status Warning - driven by the canonical readiness
verdict so the most-fundamental missing prerequisite (provider →
commands → connectivity) is the one surfaced, in one place. */}
<Show
when={
props.resourceType === 'agent' &&
!connectedAgents.loading &&
!discoveryInfo.loading
}
>
<Show when={discoveryReadiness().status === 'needs_ai_provider'}>
<div class="mb-4 mx-auto max-w-md rounded-md border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2">
<svg
class="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-xs">
<p class="font-medium text-amber-800 dark:text-amber-200">
AI provider not configured
</p>
<p class="text-amber-700 dark:text-amber-300 mt-0.5">
Discovery needs an AI provider to analyze what is running. Configure one in
Settings -&gt; AI before scanning.
</p>
</div>
</div>
</div>
</Show>
<Show when={discoveryReadiness().status === 'needs_commands'}>
<div class="mb-4 mx-auto max-w-md rounded-md border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2">
<svg
@@ -549,7 +586,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</div>
</div>
</Show>
<Show when={props.commandsEnabled === true && !hasConnectedAgent()}>
<Show when={discoveryReadiness().status === 'needs_connected_agent'}>
<div class="mb-4 mx-auto max-w-md rounded-md border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2">
<svg
@@ -585,6 +622,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</div>
</div>
</Show>
{/* Kept literal, not `status === 'ready'`: the readiness verdict
treats unknown command state as ready (don't-block), but this
green "connected" claim must require a genuinely connected
agent with commands explicitly enabled. */}
<Show when={props.commandsEnabled === true && hasConnectedAgent()}>
<div class="mb-4 mx-auto max-w-md rounded-md border border-green-200 bg-green-50 p-3 text-left dark:border-green-800 dark:bg-green-900">
<div class="flex items-center gap-2">
@@ -35,6 +35,20 @@ import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
const aiSettingsWithDiscovery = (discovery_enabled: boolean) =>
({ discovery_enabled }) as Awaited<ReturnType<typeof AIAPI.getSettings>>;
// A configured analysis provider — the most-fundamental discovery prerequisite.
// Command/connectivity guidance only surfaces once a provider exists, so tests
// that exercise those later states must establish this first.
const discoveryInfoWithProvider = () => ({
ai_provider: {
provider: 'anthropic',
model: 'claude-haiku-4-5',
is_local: false,
label: 'Cloud (Anthropic)',
},
commands: [],
command_categories: [],
});
describe('DiscoveryTab', () => {
afterEach(() => {
cleanup();
@@ -171,8 +185,27 @@ describe('DiscoveryTab', () => {
});
});
it('surfaces the AI-provider prerequisite before command guidance', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
// Default getDiscoveryInfo mock returns null → no provider configured.
render(() => (
<DiscoveryTab
resourceType="agent"
agentId="agent-1"
resourceId="agent-1"
hostname="pve1"
commandsEnabled={false}
/>
));
expect(await screen.findByText('AI provider not configured')).toBeInTheDocument();
expect(screen.queryByText('Commands not enabled')).not.toBeInTheDocument();
});
it('uses canonical settings copy for disabled command guidance', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider());
render(() => (
<DiscoveryTab
@@ -194,6 +227,7 @@ describe('DiscoveryTab', () => {
it('uses canonical API Access copy for missing command connection guidance', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider());
render(() => (
<DiscoveryTab
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { computeDiscoveryReadiness } from '../discoveryReadiness';
const base = {
discoveryEnabled: true,
aiProviderConfigured: true,
commandsEnabled: true as boolean | undefined,
hasConnectedAgent: true,
};
describe('computeDiscoveryReadiness', () => {
it('is ready when every prerequisite is met', () => {
expect(computeDiscoveryReadiness(base)).toEqual({ status: 'ready', ready: true });
});
it('reports disabled first, even when other prerequisites are unmet', () => {
expect(
computeDiscoveryReadiness({ ...base, discoveryEnabled: false, aiProviderConfigured: false }),
).toEqual({ status: 'disabled', ready: false });
});
it('reports a missing AI provider before a command-disabled agent', () => {
expect(
computeDiscoveryReadiness({
...base,
aiProviderConfigured: false,
commandsEnabled: false,
}),
).toEqual({ status: 'needs_ai_provider', ready: false });
});
it('reports commands disabled when the host agent has them off', () => {
expect(computeDiscoveryReadiness({ ...base, commandsEnabled: false })).toEqual({
status: 'needs_commands',
ready: false,
});
});
it('reports a disconnected agent when commands are on but nothing is connected', () => {
expect(
computeDiscoveryReadiness({ ...base, commandsEnabled: true, hasConnectedAgent: false }),
).toEqual({ status: 'needs_connected_agent', ready: false });
});
it('does not block on commands when their state is unknown', () => {
expect(
computeDiscoveryReadiness({ ...base, commandsEnabled: undefined, hasConnectedAgent: false }),
).toEqual({ status: 'ready', ready: true });
});
});
@@ -0,0 +1,47 @@
// Single source of truth for "can Discovery actually work here?"
//
// Discovery has real prerequisites — the feature toggle, a configured AI
// provider, and an agent with command execution ("Pulse Commands") enabled and
// connected. These were previously re-checked ad hoc in ~5 places (the settings
// section, the per-resource tab, the run gate), which is why the feature could
// be silently on-but-useless and the UI was inconsistent. Compute the verdict
// once here and let every surface render from it.
export type DiscoveryReadinessStatus =
| 'disabled' // user turned the feature off
| 'needs_ai_provider' // no AI provider configured to analyze evidence
| 'needs_commands' // AI ready, but the host agent has Pulse Commands disabled
| 'needs_connected_agent' // commands enabled, but no agent connected to run them
| 'ready';
export interface DiscoveryReadinessInputs {
/** The discovery feature toggle (Settings → AI → Workload Discovery). */
discoveryEnabled: boolean;
/** Whether at least one AI provider has credentials configured. */
aiProviderConfigured: boolean;
/**
* Whether the relevant host agent has command execution enabled. `undefined`
* means "not known for this context" (don't block on it).
*/
commandsEnabled: boolean | undefined;
/** Whether an agent is connected and able to run commands. */
hasConnectedAgent: boolean;
}
export interface DiscoveryReadiness {
status: DiscoveryReadinessStatus;
ready: boolean;
}
// Ordered most-fundamental-first: a missing AI provider matters before a
// command-disabled agent, which matters before connectivity. The first unmet
// prerequisite is the one to surface, so the user fixes them in a sensible order.
export function computeDiscoveryReadiness(input: DiscoveryReadinessInputs): DiscoveryReadiness {
if (!input.discoveryEnabled) return { status: 'disabled', ready: false };
if (!input.aiProviderConfigured) return { status: 'needs_ai_provider', ready: false };
if (input.commandsEnabled === false) return { status: 'needs_commands', ready: false };
if (input.commandsEnabled === true && !input.hasConnectedAgent) {
return { status: 'needs_connected_agent', ready: false };
}
return { status: 'ready', ready: true };
}
@@ -16,6 +16,7 @@ import {
} from '@/utils/discoveryPresentation';
import { copyToClipboard } from '@/utils/clipboard';
import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget';
import { computeDiscoveryReadiness, type DiscoveryReadiness } from './discoveryReadiness';
export interface DiscoveryTabStateProps {
resourceType: ResourceType;
@@ -106,6 +107,26 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
return agents.length === 1;
});
// Whether an AI provider is configured to analyze discovery evidence. The
// info fetch only resolves an `ai_provider` when one has credentials, so an
// absent provider (or a still-loading fetch) reads as "not configured".
const aiProviderConfigured = createMemo(
() => !discoveryInfo.loading && Boolean(discoveryInfo()?.ai_provider),
);
// Single prerequisite verdict — the canonical source every surface should
// render from instead of re-deriving disabled/provider/commands/connectivity
// ad hoc. Ordered most-fundamental-first inside computeDiscoveryReadiness.
const discoveryReadiness = createMemo<DiscoveryReadiness>(() =>
computeDiscoveryReadiness({
discoveryEnabled: discoveryFeatureEnabled(),
aiProviderConfigured: aiProviderConfigured(),
commandsEnabled: props.commandsEnabled,
hasConnectedAgent: hasConnectedAgent(),
}),
);
const canTriggerDiscovery = createMemo(
() => discoveryFeatureEnabled() && Boolean(targetAgentId()),
);
@@ -320,6 +341,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
copiedDiscoveryValue,
discovery,
discoveryFeatureKnownDisabled,
discoveryReadiness,
discoveryInfo,
editingNotes,
handleSaveNotes,