mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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 -> 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,
|
||||
|
||||
Reference in New Issue
Block a user