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,
|
copiedDiscoveryValue,
|
||||||
discovery,
|
discovery,
|
||||||
discoveryFeatureKnownDisabled,
|
discoveryFeatureKnownDisabled,
|
||||||
|
discoveryReadiness,
|
||||||
discoveryInfo,
|
discoveryInfo,
|
||||||
editingNotes,
|
editingNotes,
|
||||||
handleCopyDiscoveryValue,
|
handleCopyDiscoveryValue,
|
||||||
@@ -516,9 +517,45 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection Status Warning - Show when commands are needed but not available */}
|
{/* Connection Status Warning - driven by the canonical readiness
|
||||||
<Show when={props.resourceType === 'agent' && !connectedAgents.loading}>
|
verdict so the most-fundamental missing prerequisite (provider →
|
||||||
<Show when={props.commandsEnabled === false}>
|
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="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">
|
<div class="flex items-start gap-2">
|
||||||
<svg
|
<svg
|
||||||
@@ -549,7 +586,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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="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">
|
<div class="flex items-start gap-2">
|
||||||
<svg
|
<svg
|
||||||
@@ -585,6 +622,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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()}>
|
<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="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">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
|||||||
const aiSettingsWithDiscovery = (discovery_enabled: boolean) =>
|
const aiSettingsWithDiscovery = (discovery_enabled: boolean) =>
|
||||||
({ discovery_enabled }) as Awaited<ReturnType<typeof AIAPI.getSettings>>;
|
({ 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', () => {
|
describe('DiscoveryTab', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
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 () => {
|
it('uses canonical settings copy for disabled command guidance', async () => {
|
||||||
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
|
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
|
||||||
|
vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider());
|
||||||
|
|
||||||
render(() => (
|
render(() => (
|
||||||
<DiscoveryTab
|
<DiscoveryTab
|
||||||
@@ -194,6 +227,7 @@ describe('DiscoveryTab', () => {
|
|||||||
|
|
||||||
it('uses canonical API Access copy for missing command connection guidance', async () => {
|
it('uses canonical API Access copy for missing command connection guidance', async () => {
|
||||||
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
|
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
|
||||||
|
vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider());
|
||||||
|
|
||||||
render(() => (
|
render(() => (
|
||||||
<DiscoveryTab
|
<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';
|
} from '@/utils/discoveryPresentation';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget';
|
import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget';
|
||||||
|
import { computeDiscoveryReadiness, type DiscoveryReadiness } from './discoveryReadiness';
|
||||||
|
|
||||||
export interface DiscoveryTabStateProps {
|
export interface DiscoveryTabStateProps {
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
@@ -106,6 +107,26 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
|||||||
|
|
||||||
return agents.length === 1;
|
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(
|
const canTriggerDiscovery = createMemo(
|
||||||
() => discoveryFeatureEnabled() && Boolean(targetAgentId()),
|
() => discoveryFeatureEnabled() && Boolean(targetAgentId()),
|
||||||
);
|
);
|
||||||
@@ -320,6 +341,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
|||||||
copiedDiscoveryValue,
|
copiedDiscoveryValue,
|
||||||
discovery,
|
discovery,
|
||||||
discoveryFeatureKnownDisabled,
|
discoveryFeatureKnownDisabled,
|
||||||
|
discoveryReadiness,
|
||||||
discoveryInfo,
|
discoveryInfo,
|
||||||
editingNotes,
|
editingNotes,
|
||||||
handleSaveNotes,
|
handleSaveNotes,
|
||||||
|
|||||||
Reference in New Issue
Block a user