Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ export const ALERTZERO_APP_PATH = '/app/alertzero' as const;

export const ALERTZERO_INTERNAL_URL = '/internal/alertzero' as const;

/**
* Per-space advanced setting that gates AlertZero enablement. This is the source of truth for the
* derived onboarding states: while it is `false` the app shows the disabled CTA page. Read it from
* the browser via `uiSettings` under this key, never from request bodies.
*/
export const ALERTZERO_ENABLED_SETTING = 'alertzero:enabled' as const;

/** In-app route for the onboarding experience (S0/S1/S2 derived states). */
export const ALERTZERO_ONBOARDING_PATH = '/onboarding' as const;

/** Enable route: flips {@link ALERTZERO_ENABLED_SETTING} to `true` for the current space. */
export const ALERTZERO_ONBOARDING_ENABLE_URL =
`${ALERTZERO_INTERNAL_URL}/onboarding/enable` as const;

export const ALERTZERO_WATCHES_URL = `${ALERTZERO_INTERNAL_URL}/watches` as const;
export const ALERTZERO_WATCH_URL_TEMPLATE = `${ALERTZERO_WATCHES_URL}/{watchId}` as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export {
ALERTZERO_APP_PATH,
ALERTZERO_FEATURE_ID,
ALERTZERO_INTERNAL_URL,
ALERTZERO_ENABLED_SETTING,
ALERTZERO_ONBOARDING_PATH,
ALERTZERO_ONBOARDING_ENABLE_URL,
ALERTZERO_INVESTIGATIONS_URL,
ALERTZERO_INVESTIGATION_URL_TEMPLATE,
ALERTZERO_PLUGIN_NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@kbn/react-query';
import { coreMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
ALERTZERO_ENABLED_SETTING,
ALERTZERO_ONBOARDING_ENABLE_URL,
API_VERSIONS,
SYSTEM_SECURITY_WATCH_FLOOR_ID,
createCatalogWatchPlaceholder,
type Watch,
} from '@kbn/alertzero-common';
import { useEnableOnboarding, useOnboardingState } from './use_onboarding_state';
import { usePendingProposals } from './use_proposals_api';
import { useWatches } from './use_watches_api';

jest.mock('./use_watches_api', () => ({ useWatches: jest.fn() }));
jest.mock('./use_proposals_api', () => ({ usePendingProposals: jest.fn() }));

const mockUseWatches = jest.mocked(useWatches);
const mockUsePendingProposals = jest.mocked(usePendingProposals);

type Services = ReturnType<typeof createServices>;

const createServices = (
overrides: { enabled?: boolean; canSaveAdvancedSettings?: boolean } = {}
) => {
const base = coreMock.createStart();
return {
...base,
uiSettings: {
...base.uiSettings,
get: jest.fn().mockReturnValue(overrides.enabled ?? false),
get$: jest.fn().mockReturnValue({
subscribe: (onNext: (value: boolean) => void) => {
onNext(overrides.enabled ?? false);
return { unsubscribe: jest.fn() };
},
}),
set: jest.fn(),
},
application: {
...base.application,
capabilities: {
...base.application.capabilities,
advancedSettings: { save: overrides.canSaveAdvancedSettings ?? true },
},
},
};
};

interface OnboardingFixtures {
services: Services;
watches?: Watch[];
proposalsCount?: number;
watchesLoading?: boolean;
proposalsLoading?: boolean;
watchesError?: unknown;
}

const renderOnboarding = (fixtures: OnboardingFixtures) => {
mockUseWatches.mockReturnValue({
data: { watches: fixtures.watches ?? [] },
isLoading: fixtures.watchesLoading ?? false,
error: fixtures.watchesError ?? null,
refetch: jest.fn(),
} as never);
mockUsePendingProposals.mockReturnValue({
data: { proposals: Array.from({ length: fixtures.proposalsCount ?? 0 }) },
isLoading: fixtures.proposalsLoading ?? false,
refetch: jest.fn(),
} as never);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<KibanaContextProvider services={fixtures.services as never}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</KibanaContextProvider>
);
return { queryClient, ...renderHook(() => useOnboardingState(), { wrapper }) };
};

const unrunWatch = (): Watch => ({
...createCatalogWatchPlaceholder(SYSTEM_SECURITY_WATCH_FLOOR_ID),
metrics: { lastRun: null },
recentRuns: [],
});

describe('useOnboardingState', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('derives S0 (disabled) when the advanced setting is false, regardless of watches', () => {
const { result } = renderOnboarding({
services: createServices({ enabled: false }),
watches: [unrunWatch()],
});
expect(result.current.state).toBe('disabled');
expect(result.current.enabled).toBe(false);
expect(result.current.hasWatches).toBe(true);
});

it('derives S1 (no-watches) when enabled with no installed watches', () => {
const { result } = renderOnboarding({
services: createServices({ enabled: true }),
watches: [],
});
expect(result.current.state).toBe('no-watches');
expect(result.current.enabled).toBe(true);
expect(result.current.hasWatches).toBe(false);
});

it('derives S2 (awaiting-first-run) when a watch is installed but never ran', () => {
const { result } = renderOnboarding({
services: createServices({ enabled: true }),
watches: [unrunWatch()],
});
expect(result.current.state).toBe('awaiting-first-run');
expect(result.current.hasRun).toBe(false);
});

it('derives active when a watch has run evidence', () => {
const run = unrunWatch();
// @ts-expect-error a single recent run shape is enough for the derivation
run.recentRuns = [{}];
const { result } = renderOnboarding({
services: createServices({ enabled: true }),
watches: [run],
});
expect(result.current.state).toBe('active');
expect(result.current.hasRun).toBe(true);
});

it('derives active when proposals exist even without run evidence on the watch', () => {
const { result } = renderOnboarding({
services: createServices({ enabled: true }),
watches: [unrunWatch()],
proposalsCount: 1,
});
expect(result.current.state).toBe('active');
expect(result.current.hasRun).toBe(true);
});

it('exposes canToggle from capabilities.advancedSettings.save', () => {
const withPermission = renderOnboarding({
services: createServices({ enabled: false, canSaveAdvancedSettings: true }),
});
expect(withPermission.result.current.canToggle).toBe(true);

const withoutPermission = renderOnboarding({
services: createServices({ enabled: false, canSaveAdvancedSettings: false }),
});
expect(withoutPermission.result.current.canToggle).toBe(false);
});

it('reports isLoading while watches or proposals are still loading', () => {
const { result } = renderOnboarding({
services: createServices({ enabled: true }),
watchesLoading: true,
});
expect(result.current.isLoading).toBe(true);
});
});

describe('useEnableOnboarding', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('POSTs the enable route and flips the local advanced setting on success', async () => {
const services = createServices({ enabled: false });
const post = jest.fn().mockResolvedValue({});
const http = { ...services.http, post };
mockUseWatches.mockReturnValue({
data: { watches: [] },
isLoading: false,
error: null,
refetch: jest.fn(),
} as never);
mockUsePendingProposals.mockReturnValue({
data: { proposals: [] },
isLoading: false,
refetch: jest.fn(),
} as never);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<KibanaContextProvider services={{ ...services, http } as never}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</KibanaContextProvider>
);
const { result } = renderHook(() => useEnableOnboarding(), { wrapper });

act(() => {
result.current.mutate();
});

await act(async () => {
await Promise.resolve();
});

expect(post).toHaveBeenCalledWith(ALERTZERO_ONBOARDING_ENABLE_URL, {
version: API_VERSIONS.internal.v1,
});
expect(services.uiSettings.set).toHaveBeenCalledWith(ALERTZERO_ENABLED_SETTING, true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@kbn/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
ALERTZERO_ENABLED_SETTING,
ALERTZERO_ONBOARDING_ENABLE_URL,
API_VERSIONS,
} from '@kbn/alertzero-common';
import { queryKeys } from '../query_keys';
import { usePendingProposals } from './use_proposals_api';
import { useWatches } from './use_watches_api';

/**
* Derived onboarding state. There is no stored step — the state is recomputed from the
* `alertzero:enabled` advanced setting, the installed watches, and any generated proposals.
*
* S0 `disabled` — advanced setting is `false`. Show the CTA + enable toggle.
* S1 `no-watches` — enabled but no watches installed. Show empty state + watch catalog link.
* S2 `awaiting-first-run` — watches installed but none have produced a run yet.
* `active` — a run exists; onboarding is complete and the app no longer needs this page.
*/
export type OnboardingState = 'disabled' | 'no-watches' | 'awaiting-first-run' | 'active';

export interface UseOnboardingStateResult {
state: OnboardingState;
/** Source of truth: the `alertzero:enabled` advanced setting, kept reactive to uiSettings changes. */
enabled: boolean;
hasWatches: boolean;
hasRun: boolean;
/** Watches and proposals queries are still loading. */
isLoading: boolean;
/** Whether the current user may flip the setting (`capabilities.advancedSettings.save`). */
canToggle: boolean;
watchesError: unknown;
}

export const useOnboardingState = (): UseOnboardingStateResult => {
const { services } = useKibana();
const uiSettings = services.uiSettings;

const [enabled, setEnabled] = useState<boolean>(() =>
uiSettings ? uiSettings.get<boolean>(ALERTZERO_ENABLED_SETTING, false) : false
);

useEffect(() => {
if (!uiSettings) {
return;
}
const subscription = uiSettings
.get$(ALERTZERO_ENABLED_SETTING, false)
.subscribe((value) => setEnabled(value));
return () => subscription.unsubscribe();
}, [uiSettings]);

const canToggle = services.application?.capabilities?.advancedSettings?.save === true;

const { data: watchesData, isLoading: watchesLoading, error: watchesError } = useWatches();
const { data: proposalsData, isLoading: proposalsLoading } = usePendingProposals();

const watches = watchesData?.watches ?? [];
const proposals = proposalsData?.proposals ?? [];

const hasWatches = watches.length > 0;
const hasRun =
proposals.length > 0 ||
watches.some((watch) => (watch.recentRuns?.length ?? 0) > 0 || watch.metrics?.lastRun != null);

const state: OnboardingState = !enabled
? 'disabled'
: !hasWatches
? 'no-watches'
: hasRun
? 'active'
: 'awaiting-first-run';

return {
state,
enabled,
hasWatches,
hasRun,
isLoading: watchesLoading || proposalsLoading,
canToggle,
watchesError,
};
};

/**
* Flips the `alertzero:enabled` advanced setting on via the enable route. The route is the
* authoritative source of truth; on success we optimistically update the local uiSettings cache so
* {@link useOnboardingState} re-derives without a full reload, and refetch watches + proposals.
*/
export const useEnableOnboarding = () => {
const { services } = useKibana();
const queryClient = useQueryClient();

return useMutation({
mutationFn: (): Promise<unknown> =>
services.http!.post(ALERTZERO_ONBOARDING_ENABLE_URL, {
version: API_VERSIONS.internal.v1,
}),
onSuccess: () => {
services.uiSettings?.set(ALERTZERO_ENABLED_SETTING, true);
void queryClient.invalidateQueries({ queryKey: queryKeys.watches.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.proposals.all });
},
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type { GetWatchResponse, ListWatchesResponse } from '@kbn/alertzero-commo
import { queryKeys } from '../query_keys';

export const retryOnTransientError = (failureCount: number, error: unknown): boolean => {
if (failureCount >= 3) {
// Retry a transient (5xx) error exactly once. A second consecutive failure means the
// backend is not going to recover in the time a user is looking at the page, so we
// surface the empty state instead of leaving the onboarding gate stuck on a spinner.
if (failureCount >= 1) {
return false;
}
if (isHttpFetchError(error)) {
Expand Down
Loading
Loading