feat(slo): remove feature flag (#152834)

This commit is contained in:
Kevin Delemme 2023-03-08 14:45:05 -05:00 committed by GitHub
parent b449264e61
commit a04c420569
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1144 additions and 697 deletions

View file

@ -134,6 +134,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"siem-ui-timeline": "e9d6b3a9fd7af6dc502293c21cbdb309409f3996",
"siem-ui-timeline-note": "13c9d4c142f96624a93a623c6d7cba7e1ae9b5a6",
"siem-ui-timeline-pinned-event": "96a43d59b9e2fc11f12255a0cb47ef0a3d83af4c",
"slo": "9a138b459c7efef7fecfda91f22db8b7655d0e61",
"space": "9542afcd6fd71558623c09151e453c5e84b4e5e1",
"spaces-usage-stats": "084bd0f080f94fb5735d7f3cf12f13ec92f36bad",
"synthetics-monitor": "96cc312bfa597022f83dfb3b5d1501e27a73e8d5",

View file

@ -107,6 +107,7 @@ const previouslyRegisteredTypes = [
'siem-ui-timeline',
'siem-ui-timeline-note',
'siem-ui-timeline-pinned-event',
'slo',
'space',
'spaces-usage-stats',
'synthetics-monitor',

View file

@ -300,7 +300,6 @@ kibana_vars=(
xpack.ingestManager.fleet.tlsCheckDisabled
xpack.ingestManager.registryUrl
xpack.observability.annotations.index
xpack.observability.unsafe.slo.enabled
xpack.observability.unsafe.alertDetails.apm.enabled
xpack.observability.unsafe.alertDetails.metrics.enabled
xpack.observability.unsafe.alertDetails.logs.enabled

View file

@ -232,7 +232,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)',
'xpack.observability.unsafe.slo.enabled (boolean)',
];
// We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large
// arrays are hard to grok. Instead, we take the difference between the two arrays and assert them separately, that way it's

View file

@ -13,16 +13,6 @@ xpack.ruleRegistry.write.enabled: true
When this is set to `true`, your alerts should show on the alerts page.
## SLOs
If you have:
```yaml
xpack.observability.unsafe.slo.enabled: true
```
In your Kibana configuration, the SLO feature will be available.
## Shared navigation

View file

@ -1,6 +1,6 @@
# SLO
Add the feature flag: `xpack.observability.unsafe.slo.enabled: true` in your Kibana config to enable the various SLO APIs.
Starting in 8.8, SLO is enabled by default.
## Supported SLI

View file

@ -0,0 +1,522 @@
/*
* 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 { makeDecorator } from '@storybook/addons';
import { storiesOf } from '@storybook/react';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import React, { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { HasDataContextProvider } from '../../context/has_data_context';
import { PluginContext } from '../../context/plugin_context';
import { registerDataHandler, unregisterDataHandler } from '../../data_handler';
import { OverviewPage } from './overview';
import { alertsFetchData } from './mock/alerts.mock';
import { emptyResponse as emptyAPMResponse, fetchApmData } from './mock/apm.mock';
import { emptyResponse as emptyLogsResponse, fetchLogsData } from './mock/logs.mock';
import { emptyResponse as emptyMetricsResponse, fetchMetricsData } from './mock/metrics.mock';
import { newsFeedFetchData } from './mock/news_feed.mock';
import { emptyResponse as emptyUptimeResponse, fetchUptimeData } from './mock/uptime.mock';
import { createObservabilityRuleTypeRegistryMock } from '../../rules/observability_rule_type_registry_mock';
import { ApmIndicesConfig } from '../../../common/typings';
import { ConfigSchema } from '../../plugin';
function unregisterAll() {
unregisterDataHandler({ appName: 'apm' });
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'synthetics' });
}
const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig;
const withCore = makeDecorator({
name: 'withCore',
parameterName: 'core',
wrapper: (storyFn, context) => {
unregisterAll();
const KibanaReactContext = createKibanaReactContext({
application: {
getUrlForApp: () => '',
capabilities: { navLinks: { integrations: true } },
currentAppId$: {
subscribe: () => {},
},
},
data: {
query: {
timefilter: {
timefilter: {
setTime: () => {},
getTime: () => ({}),
},
},
},
},
http: {
basePath: {
prepend: (link: string) => `http://localhost:5601${link}`,
},
},
chrome: {
docTitle: {
change: () => {},
},
},
uiSettings: { get: () => [] },
usageCollection: {
reportUiCounter: () => {},
},
} as unknown as Partial<CoreStart>);
const config: ConfigSchema = {
unsafe: {
alertDetails: {
apm: { enabled: false },
logs: { enabled: false },
metrics: { enabled: false },
uptime: { enabled: false },
},
},
};
return (
<MemoryRouter>
<KibanaReactContext.Provider>
<PluginContext.Provider
value={{
appMountParameters: {
setHeaderActionMenu: () => {},
} as unknown as AppMountParameters,
config,
observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(),
ObservabilityPageTemplate: KibanaPageTemplate,
}}
>
<HasDataContextProvider>{storyFn(context) as ReactNode}</HasDataContextProvider>
</PluginContext.Provider>
</KibanaReactContext.Provider>
</MemoryRouter>
);
},
});
const core = {
http: {
basePath: {
prepend: (link: string) => `http://localhost:5601${link}`,
},
get: () => Promise.resolve({ data: [] }),
},
uiSettings: {
get: (key: string) => {
const euiSettings = {
[UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS]: {
from: 'now-15m',
to: 'now',
},
[UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS]: {
pause: true,
value: 1000,
},
[UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
},
{
from: 'now-15m',
to: 'now',
display: 'Last 15 minutes',
},
{
from: 'now-30m',
to: 'now',
display: 'Last 30 minutes',
},
{
from: 'now-1h',
to: 'now',
display: 'Last 1 hour',
},
{
from: 'now-24h',
to: 'now',
display: 'Last 24 hours',
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
},
],
};
// @ts-expect-error
return euiSettings[key];
},
},
docLinks: {
links: {
observability: {
guide: 'alink',
},
},
},
} as unknown as CoreStart;
const coreWithAlerts = {
...core,
http: {
...core.http,
get: alertsFetchData,
},
} as unknown as CoreStart;
const coreWithNewsFeed = {
...core,
http: {
...core.http,
get: newsFeedFetchData,
},
} as unknown as CoreStart;
const coreAlertsThrowsError = {
...core,
http: {
...core.http,
get: async () => {
throw new Error('Error fetching Alerts data');
},
},
} as unknown as CoreStart;
storiesOf('app/Overview', module)
.addDecorator(withCore(core))
.add('Empty State', () => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => ({ hasData: false, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: false, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: false, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
hasData: async () => ({ hasData: false, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
})
.add('Single Panel', () => {
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
return <OverviewPage />;
})
.add('Logs and Metrics', () => {
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
return <OverviewPage />;
})
.add(
'Logs, Metrics, and Alerts',
() => {
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
return <OverviewPage />;
},
{ core: coreWithAlerts }
)
.add(
'Logs, Metrics, APM, and Alerts',
() => {
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
return <OverviewPage />;
},
{ core: coreWithAlerts }
)
.add('Logs, Metrics, APM, and Uptime', () => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
})
.add(
'Logs, Metrics, APM, Uptime, and Alerts',
() => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
},
{ core: coreWithAlerts }
)
.add(
'Logs, Metrics, APM, Uptime, and News Feed',
() => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
},
{ core: coreWithNewsFeed }
)
.add('No Data', () => {
registerDataHandler({
appName: 'apm',
fetchData: async () => emptyAPMResponse,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: async () => emptyLogsResponse,
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: async () => emptyMetricsResponse,
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: async () => emptyUptimeResponse,
hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
})
.add(
'Fetch Data with Error',
() => {
registerDataHandler({
appName: 'apm',
fetchData: async () => {
throw new Error('Error fetching APM data');
},
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
fetchData: async () => {
throw new Error('Error fetching Logs data');
},
hasData: async () => ({ hasData: true, indices: 'test-index' }),
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: async () => {
throw new Error('Error fetching Metric data');
},
hasData: async () => ({ hasData: true, indices: 'metric-*' }),
});
registerDataHandler({
appName: 'synthetics',
fetchData: async () => {
throw new Error('Error fetching Uptime data');
},
hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }),
});
return <OverviewPage />;
},
{ core: coreAlertsThrowsError }
)
.add(
'hasData with Error and Alerts',
() => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
return <OverviewPage />;
},
{ core: coreWithAlerts }
)
.add('hasData with Error', () => {
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
registerDataHandler({
appName: 'synthetics',
fetchData: fetchUptimeData,
// @ts-ignore throws an error instead
hasData: async () => {
throw new Error('Error has data');
},
});
return <OverviewPage />;
});

View file

@ -15,8 +15,6 @@ import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { render } from '../../utils/test_helper';
import { SloDetailsPage } from './slo_details';
import { buildSlo } from '../../data/slo/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { paths } from '../../config';
import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_summary';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
@ -65,12 +63,6 @@ const mockKibana = () => {
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
},
};
describe('SLO Details Page', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -82,67 +74,52 @@ describe('SLO Details Page', () => {
});
});
describe('when the feature flag is not enabled', () => {
it('renders the not found page', async () => {
describe('when the incorrect license is found', () => {
it('navigates to the SLO List page', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
render(<SloDetailsPage />);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
describe('when the correct license is found', () => {
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('nonexistent');
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the loading spinner when fetching the SLO', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: true, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('sloDetailsLoading')).toBeTruthy();
});
it('renders the SLO details page', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, { unsafe: { slo: { enabled: false } } });
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
});
describe('when the feature flag is enabled', () => {
describe('when the incorrect license is found', () => {
it('navigates to the SLO List page', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
render(<SloDetailsPage />, { unsafe: { slo: { enabled: true } } });
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
describe('when the correct license is found', () => {
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('nonexistent');
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the loading spinner when fetching the SLO', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: true, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('sloDetailsLoading')).toBeTruthy();
});
it('renders the SLO details page', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
});
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
});
});
});

View file

@ -19,7 +19,6 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useLicense } from '../../hooks/use_license';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
import { SloDetails } from './components/slo_details';
import { HeaderTitle } from './components/header_title';
import { paths } from '../../config';
@ -32,7 +31,7 @@ export function SloDetailsPage() {
application: { navigateToUrl },
http: { basePath },
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { ObservabilityPageTemplate } = usePluginContext();
const { hasAtLeast } = useLicense();
const hasRightLicense = hasAtLeast('platinum');
@ -41,7 +40,7 @@ export function SloDetailsPage() {
useBreadcrumbs(getBreadcrumbs(basePath, slo));
const isSloNotFound = !isLoading && slo === undefined;
if (!isSloFeatureEnabled(config) || isSloNotFound) {
if (isSloNotFound) {
return <PageNotFound />;
}

View file

@ -19,8 +19,6 @@ import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useCreateSlo } from '../../hooks/slo/use_create_slo';
import { useUpdateSlo } from '../../hooks/slo/use_update_slo';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from './constants';
import { buildSlo } from '../../data/slo/slo';
import { paths } from '../../config';
@ -102,12 +100,6 @@ const mockKibana = () => {
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
},
};
describe('SLO Edit Page', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -119,38 +111,51 @@ describe('SLO Edit Page', () => {
afterEach(cleanup);
describe('when the feature flag is disabled', () => {
it('renders the not found page when no sloId param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
describe('when the incorrect license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the not found page when sloId param is passed', async () => {
it('navigates to the SLO List page', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
useCreateSloMock.mockReturnValue({
isLoading: false,
isSuccess: false,
isError: false,
mutate: jest.fn(),
mutateAsync: jest.fn(),
});
useUpdateSloMock.mockReturnValue({
isLoading: false,
isSuccess: false,
isError: false,
mutate: jest.fn(),
mutateAsync: jest.fn(),
});
render(<SloEditPage />);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
describe('when the feature flag is enabled', () => {
describe('when the incorrect license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
});
describe('when the correct license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
});
it('navigates to the SLO List page', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
describe('when no sloId route param is provided', () => {
it('renders the SLO Edit page in pristine state', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
@ -160,154 +165,110 @@ describe('SLO Edit Page', () => {
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
mutate: jest.fn(),
mutateAsync: jest.fn(),
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
mutate: jest.fn(),
mutateAsync: jest.fn(),
});
render(<SloEditPage />, config);
render(<SloEditPage />);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
describe('when the correct license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toBeNull();
expect(screen.queryByTestId('customKqlIndicatorFormQueryFilterInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
: ''
);
expect(screen.queryByTestId('customKqlIndicatorFormGoodQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
: ''
);
expect(screen.queryByTestId('customKqlIndicatorFormTotalQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
: ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.name
);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.description
);
});
describe('when no sloId route param is provided', () => {
it('renders the SLO Edit page in pristine state', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toBeNull();
expect(screen.queryByTestId('customKqlIndicatorFormQueryFilterInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
: ''
);
expect(screen.queryByTestId('customKqlIndicatorFormGoodQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
: ''
);
expect(screen.queryByTestId('customKqlIndicatorFormTotalQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
: ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.name
);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.description
);
it.skip('calls the createSlo hook if all required values are filled in', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
it.skip('calls the createSlo hook if all required values are filled in', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
useFetchSloMock.mockReturnValue({ isLoading: false, slo: undefined });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useCreateSloMock.mockReturnValue({
mutateAsync: mockCreate,
isLoading: false,
isSuccess: false,
isError: false,
});
useCreateSloMock.mockReturnValue({
mutateAsync: mockCreate,
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: mockUpdate,
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: mockUpdate,
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
render(<SloEditPage />, config);
userEvent.type(screen.getByTestId('indexSelection'), 'some-index');
userEvent.type(screen.getByTestId('customKqlIndicatorFormQueryFilterInput'), 'irrelevant');
userEvent.type(screen.getByTestId('customKqlIndicatorFormGoodQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('customKqlIndicatorFormTotalQueryInput'), 'irrelevant');
userEvent.selectOptions(screen.getByTestId('sloFormBudgetingMethodSelect'), 'occurrences');
userEvent.selectOptions(screen.getByTestId('sloFormTimeWindowDurationSelect'), '7d');
userEvent.clear(screen.getByTestId('sloFormObjectiveTargetInput'));
userEvent.type(screen.getByTestId('sloFormObjectiveTargetInput'), '98.5');
userEvent.type(screen.getByTestId('sloFormNameInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormDescriptionTextArea'), 'irrelevant');
userEvent.type(screen.getByTestId('indexSelection'), 'some-index');
userEvent.type(
screen.getByTestId('customKqlIndicatorFormQueryFilterInput'),
'irrelevant'
);
userEvent.type(screen.getByTestId('customKqlIndicatorFormGoodQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('customKqlIndicatorFormTotalQueryInput'), 'irrelevant');
userEvent.selectOptions(
screen.getByTestId('sloFormBudgetingMethodSelect'),
'occurrences'
);
userEvent.selectOptions(screen.getByTestId('sloFormTimeWindowDurationSelect'), '7d');
userEvent.clear(screen.getByTestId('sloFormObjectiveTargetInput'));
userEvent.type(screen.getByTestId('sloFormObjectiveTargetInput'), '98.5');
userEvent.type(screen.getByTestId('sloFormNameInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormDescriptionTextArea'), 'irrelevant');
const t = Date.now();
await waitFor(() => expect(screen.getByTestId('sloFormSubmitButton')).toBeEnabled());
console.log('end waiting for submit button: ', Math.ceil(Date.now() - t));
const t = Date.now();
await waitFor(() => expect(screen.getByTestId('sloFormSubmitButton')).toBeEnabled());
console.log('end waiting for submit button: ', Math.ceil(Date.now() - t));
fireEvent.click(screen.getByTestId('sloFormSubmitButton')!);
fireEvent.click(screen.getByTestId('sloFormSubmitButton')!);
expect(mockCreate).toMatchInlineSnapshot(`
expect(mockCreate).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
@ -342,108 +303,106 @@ describe('SLO Edit Page', () => {
],
}
`);
});
});
describe('when a sloId route param is provided', () => {
it('renders the SLO Edit page with prefilled form values', async () => {
const slo = buildSlo({ id: '123' });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(slo.indicator.type);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toHaveTextContent(
slo.indicator.params.index!
);
expect(screen.queryByTestId('customKqlIndicatorFormQueryFilterInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.filter : ''
);
expect(screen.queryByTestId('customKqlIndicatorFormGoodQueryInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.good : ''
);
expect(screen.queryByTestId('customKqlIndicatorFormTotalQueryInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.total : ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
slo.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
slo.timeWindow.duration
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
slo.objective.target * 100
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(slo.name);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(slo.description);
});
describe('when a sloId route param is provided', () => {
it('renders the SLO Edit page with prefilled form values', async () => {
const slo = buildSlo({ id: '123' });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
it('calls the updateSlo hook if all required values are filled in', async () => {
const slo = buildSlo({ id: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
slo.indicator.type
);
expect(screen.queryByTestId('indexSelectionSelectedValue')).toHaveTextContent(
slo.indicator.params.index!
);
expect(screen.queryByTestId('customKqlIndicatorFormQueryFilterInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.filter : ''
);
expect(screen.queryByTestId('customKqlIndicatorFormGoodQueryInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.good : ''
);
expect(screen.queryByTestId('customKqlIndicatorFormTotalQueryInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.total : ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
slo.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
slo.timeWindow.duration
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
slo.objective.target * 100
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(slo.name);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(slo.description);
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
it('calls the updateSlo hook if all required values are filled in', async () => {
const slo = buildSlo({ id: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: mockCreate,
isLoading: false,
isSuccess: false,
isError: false,
});
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useUpdateSloMock.mockReturnValue({
mutateAsync: mockUpdate,
isLoading: false,
isSuccess: false,
isError: false,
});
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
render(<SloEditPage />);
useCreateSloMock.mockReturnValue({
mutateAsync: mockCreate,
isLoading: false,
isSuccess: false,
isError: false,
});
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
useUpdateSloMock.mockReturnValue({
mutateAsync: mockUpdate,
isLoading: false,
isSuccess: false,
isError: false,
});
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
render(<SloEditPage />, config);
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
expect(mockUpdate).toMatchInlineSnapshot(`
expect(mockUpdate).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
@ -486,138 +445,137 @@ describe('SLO Edit Page', () => {
],
}
`);
});
it('blocks submitting if not all required values are filled in', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [],
});
useFetchSloMock.mockReturnValue({ isLoading: false, slo: { ...slo, name: '' } });
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
});
});
});
describe('when submitting has completed successfully', () => {
it('renders a success toast', async () => {
const slo = buildSlo();
it('blocks submitting if not all required values are filled in', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddSuccess).toBeCalled();
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [],
});
it('navigates to the SLO List page', async () => {
const slo = buildSlo();
useFetchSloMock.mockReturnValue({ isLoading: false, slo: { ...slo, name: '' } });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
render(<SloEditPage />);
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
});
});
});
describe('when submitting has not completed successfully', () => {
it('renders an error toast', async () => {
const slo = buildSlo();
describe('when submitting has completed successfully', () => {
it('renders a success toast', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddError).toBeCalled();
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddSuccess).toBeCalled();
});
it('navigates to the SLO List page', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
describe('when submitting has not completed successfully', () => {
it('renders an error toast', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddError).toBeCalled();
});
});
});

View file

@ -16,15 +16,13 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useLicense } from '../../hooks/use_license';
import { SloEditForm } from './components/slo_edit_form';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
export function SloEditPage() {
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { ObservabilityPageTemplate } = usePluginContext();
const { sloId } = useParams<{ sloId: string | undefined }>();
@ -42,10 +40,6 @@ export function SloEditPage() {
const { slo, isLoading } = useFetchSloDetails(sloId || '');
if (!isSloFeatureEnabled(config)) {
return <PageNotFound />;
}
if (hasRightLicense === false) {
navigateToUrl(basePath.prepend(paths.observability.slos));
}

View file

@ -0,0 +1,25 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { HeaderTitle as Component } from './header_title';
export default {
component: Component,
title: 'app/SLO/ListPage/HeaderTitle',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = () => <Component />;
const defaultProps = {};
export const Default = Template.bind({});
Default.args = defaultProps;

View file

@ -0,0 +1,41 @@
/*
* 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 { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export function HeaderTitle() {
return (
<EuiFlexGroup
direction="row"
gutterSize="m"
alignItems="center"
justifyContent="flexStart"
responsive={false}
>
<EuiFlexItem grow={false}>
{i18n.translate('xpack.observability.slosPageTitle', {
defaultMessage: 'SLOs',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label="Beta"
tooltipPosition="bottom"
tooltipContent={i18n.translate(
'xpack.observability.slo.slosPage.headerTitle.betaBadgeDescription',
{
defaultMessage:
'This functionality is in beta and is subject to change. The design and code is less mature than official generally available features and is being provided as-is with no warranties. Beta features are not subject to the support service level agreement of official generally available features.',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,12 +0,0 @@
/*
* 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 { ConfigSchema } from '../../../plugin';
export function isSloFeatureEnabled(config: ConfigSchema): boolean {
return config.unsafe.slo.enabled === true;
}

View file

@ -21,8 +21,6 @@ import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_
import { useLicense } from '../../hooks/use_license';
import { SlosPage } from '.';
import { emptySloList, sloList } from '../../data/slo/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { historicalSummaryData } from '../../data/slo/historical_summary_data';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
@ -83,12 +81,6 @@ const mockKibana = () => {
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
},
};
describe('SLOs Page', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -96,51 +88,68 @@ describe('SLOs Page', () => {
useCapabilitiesMock.mockReturnValue({ hasWriteCapabilities: true, hasReadCapabilities: true });
});
describe('when the feature flag is not enabled', () => {
it('renders the not found page ', async () => {
describe('when the incorrect license is found', () => {
it('renders the welcome prompt with subscription buttons', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
await act(async () => {
render(<SlosPage />, { unsafe: { slo: { enabled: false } } });
render(<SlosPage />);
});
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForCloudButton')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForLicenseButton')).toBeTruthy();
});
});
describe('when the feature flag is enabled', () => {
describe('when the incorrect license is found', () => {
it('renders the welcome prompt with subscription buttons', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
await act(async () => {
render(<SlosPage />, config);
});
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForCloudButton')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForLicenseButton')).toBeTruthy();
});
describe('when the correct license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
});
describe('when the correct license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
it('renders the SLOs Welcome Prompt when the API has finished loading and there are no results', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
await act(async () => {
render(<SlosPage />);
});
it('renders the SLOs Welcome Prompt when the API has finished loading and there are no results', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
});
await act(async () => {
render(<SlosPage />, config);
});
it('should have a create new SLO button', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
it('should have a create new SLO button', async () => {
await act(async () => {
render(<SlosPage />);
});
expect(screen.getByText('Create new SLO')).toBeTruthy();
});
it('should have an Auto Refresh button', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
await act(async () => {
render(<SlosPage />);
});
expect(screen.getByTestId('autoRefreshButton')).toBeTruthy();
});
describe('when API has returned results', () => {
it('renders the SLO list with SLO items', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
@ -149,13 +158,16 @@ describe('SLOs Page', () => {
});
await act(async () => {
render(<SlosPage />, config);
render(<SlosPage />);
});
expect(screen.getByText('Create new SLO')).toBeTruthy();
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem').length).toBe(sloList.results.length);
});
it('should have an Auto Refresh button', async () => {
it('allows editing an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
@ -164,107 +176,72 @@ describe('SLOs Page', () => {
});
await act(async () => {
render(<SlosPage />, config);
render(<SlosPage />);
});
expect(screen.getByTestId('autoRefreshButton')).toBeTruthy();
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
const button = screen.getByTestId('sloActionsEdit');
expect(button).toBeTruthy();
button.click();
expect(mockNavigate).toBeCalled();
});
describe('when API has returned results', () => {
it('renders the SLO list with SLO items', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
it('allows deleting an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
await act(async () => {
render(<SlosPage />, config);
});
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem').length).toBe(sloList.results.length);
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
it('allows editing an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
await act(async () => {
render(<SlosPage />, config);
});
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
const button = screen.getByTestId('sloActionsEdit');
expect(button).toBeTruthy();
button.click();
expect(mockNavigate).toBeCalled();
await act(async () => {
render(<SlosPage />);
});
it('allows deleting an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
screen.getAllByLabelText('Actions').at(0)?.click();
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
await waitForEuiPopoverOpen();
await act(async () => {
render(<SlosPage />, config);
});
const button = screen.getByTestId('sloActionsDelete');
screen.getAllByLabelText('Actions').at(0)?.click();
expect(button).toBeTruthy();
await waitForEuiPopoverOpen();
button.click();
const button = screen.getByTestId('sloActionsDelete');
screen.getByTestId('confirmModalConfirmButton').click();
expect(button).toBeTruthy();
expect(mockDeleteSlo).toBeCalledWith({ id: sloList.results.at(0)?.id });
});
button.click();
it('allows cloning an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
screen.getByTestId('confirmModalConfirmButton').click();
expect(mockDeleteSlo).toBeCalledWith({ id: sloList.results.at(0)?.id });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
it('allows cloning an SLO', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: historicalSummaryData,
});
await act(async () => {
render(<SlosPage />, config);
});
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
const button = screen.getByTestId('sloActionsClone');
expect(button).toBeTruthy();
button.click();
expect(mockCloneSlo).toBeCalled();
await act(async () => {
render(<SlosPage />);
});
screen.getAllByLabelText('Actions').at(0)?.click();
await waitForEuiPopoverOpen();
const button = screen.getByTestId('sloActionsClone');
expect(button).toBeTruthy();
button.click();
expect(mockCloneSlo).toBeCalled();
});
});
});

View file

@ -18,17 +18,16 @@ import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { SloList } from './components/slo_list';
import { SloListWelcomePrompt } from './components/slo_list_welcome_prompt';
import { AutoRefreshButton } from './components/auto_refresh_button';
import PageNotFound from '../404';
import { paths } from '../../config';
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
import type { ObservabilityAppServices } from '../../application/types';
import { HeaderTitle } from './components/header_title';
export function SlosPage() {
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { ObservabilityPageTemplate } = usePluginContext();
const { hasWriteCapabilities } = useCapabilities();
const { hasAtLeast } = useLicense();
@ -55,10 +54,6 @@ export function SlosPage() {
setIsAutoRefreshing(!isAutoRefreshing);
};
if (!isSloFeatureEnabled(config)) {
return <PageNotFound />;
}
if (isInitialLoading) {
return null;
}
@ -70,9 +65,7 @@ export function SlosPage() {
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: i18n.translate('xpack.observability.slosPageTitle', {
defaultMessage: 'SLOs',
}),
pageTitle: <HeaderTitle />,
rightSideItems: [
<EuiButton
color="primary"

View file

@ -62,9 +62,6 @@ import { registerObservabilityRuleTypes } from './rules/register_observability_r
export interface ConfigSchema {
unsafe: {
slo: {
enabled: boolean;
};
alertDetails: {
apm: {
enabled: boolean;
@ -291,11 +288,11 @@ export class Plugin
// See https://github.com/elastic/kibana/issues/103325.
const otherLinks: NavigationEntry[] = deepLinks
.filter((link) => link.navLinkStatus === AppNavLinkStatus.visible)
.filter((link) => (link.id === 'slos' ? config.unsafe.slo.enabled : link)) // might not be useful anymore
.map((link) => ({
app: observabilityAppId,
label: link.title,
path: link.path ?? '',
isBetaFeature: link.id === 'slos' ? true : false,
}));
const sections = [
@ -325,12 +322,9 @@ export class Plugin
const { application } = coreStart;
const config = this.initContext.config.get();
const filterSlo = (link: AppDeepLink) =>
link.id === 'slos' ? config.unsafe.slo.enabled : link;
updateGlobalNavigation({
capabilities: application.capabilities,
deepLinks: this.deepLinks.filter(filterSlo),
deepLinks: this.deepLinks,
updater$: this.appUpdater$,
});

View file

@ -17,32 +17,30 @@ export const registerObservabilityRuleTypes = (
config: ConfigSchema,
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry
) => {
if (config.unsafe.slo.enabled) {
observabilityRuleTypeRegistry.register({
id: SLO_BURN_RATE_RULE_ID,
description: i18n.translate('xpack.observability.slo.rules.burnRate.description', {
defaultMessage: 'Alert when your SLO burn rate is too high over a defined period of time.',
}),
format: ({ fields }) => {
return {
reason: fields[ALERT_REASON] ?? '-',
link: '/app/observability/slos',
};
},
iconClass: 'bell',
documentationUrl(docLinks) {
return '/unknown/docs';
},
ruleParamsExpression: lazy(() => import('../components/app/burn_rate_rule_editor')),
validate: validateBurnRateRule,
requiresAppContext: false,
defaultActionMessage: i18n.translate(
'xpack.observability.slo.rules.burnRate.defaultActionMessage',
{
defaultMessage: `\\{\\{rule.name\\}\\} is firing:
observabilityRuleTypeRegistry.register({
id: SLO_BURN_RATE_RULE_ID,
description: i18n.translate('xpack.observability.slo.rules.burnRate.description', {
defaultMessage: 'Alert when your SLO burn rate is too high over a defined period of time.',
}),
format: ({ fields }) => {
return {
reason: fields[ALERT_REASON] ?? '-',
link: '/app/observability/slos',
};
},
iconClass: 'bell',
documentationUrl(docLinks) {
return '/unknown/docs';
},
ruleParamsExpression: lazy(() => import('../components/app/burn_rate_rule_editor')),
validate: validateBurnRateRule,
requiresAppContext: false,
defaultActionMessage: i18n.translate(
'xpack.observability.slo.rules.burnRate.defaultActionMessage',
{
defaultMessage: `\\{\\{rule.name\\}\\} is firing:
- Reason: \\{\\{context.reason\\}\\}`,
}
),
});
}
}
),
});
};

View file

@ -22,9 +22,6 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
const config: ConfigSchema = {
unsafe: {
slo: {
enabled: false,
},
alertDetails: {
apm: { enabled: false },
logs: { enabled: false },

View file

@ -31,9 +31,6 @@ export const data = dataPluginMock.createStartContract();
const defaultConfig: ConfigSchema = {
unsafe: {
slo: {
enabled: false,
},
alertDetails: {
apm: { enabled: false },
logs: { enabled: false },

View file

@ -29,9 +29,6 @@ const configSchema = schema.object({
index: schema.string({ defaultValue: 'observability-annotations' }),
}),
unsafe: schema.object({
slo: schema.object({
enabled: schema.boolean({ defaultValue: false }),
}),
alertDetails: schema.object({
apm: schema.object({
enabled: schema.boolean({ defaultValue: false }),

View file

@ -167,76 +167,72 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
const { ruleDataService } = plugins.ruleRegistry;
if (config.unsafe.slo.enabled) {
plugins.features.registerKibanaFeature({
id: sloFeatureId,
name: i18n.translate('xpack.observability.featureRegistry.linkSloTitle', {
defaultMessage: 'SLOs',
}),
order: 1200,
category: DEFAULT_APP_CATEGORIES.observability,
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
alerting: [SLO_BURN_RATE_RULE_ID],
privileges: {
all: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_write', 'slo_read', 'rac'],
savedObject: {
all: [SO_SLO_TYPE],
read: [],
},
alerting: {
rule: {
all: [SLO_BURN_RATE_RULE_ID],
},
alert: {
all: [SLO_BURN_RATE_RULE_ID],
},
},
ui: ['read', 'write'],
plugins.features.registerKibanaFeature({
id: sloFeatureId,
name: i18n.translate('xpack.observability.featureRegistry.linkSloTitle', {
defaultMessage: 'SLOs',
}),
order: 1200,
category: DEFAULT_APP_CATEGORIES.observability,
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
alerting: [SLO_BURN_RATE_RULE_ID],
privileges: {
all: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_write', 'slo_read', 'rac'],
savedObject: {
all: [SO_SLO_TYPE],
read: [],
},
read: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_read', 'rac'],
savedObject: {
all: [],
read: [SO_SLO_TYPE],
alerting: {
rule: {
all: [SLO_BURN_RATE_RULE_ID],
},
alerting: {
rule: {
read: [SLO_BURN_RATE_RULE_ID],
},
alert: {
read: [SLO_BURN_RATE_RULE_ID],
},
alert: {
all: [SLO_BURN_RATE_RULE_ID],
},
ui: ['read'],
},
ui: ['read', 'write'],
},
});
core.savedObjects.registerType(slo);
const ruleDataClient = ruleDataService.initializeIndex({
feature: sloFeatureId,
registrationContext: SLO_RULE_REGISTRATION_CONTEXT,
dataset: Dataset.alerts,
componentTemplateRefs: [ECS_COMPONENT_TEMPLATE_NAME],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(legacyExperimentalFieldMap, 'strict'),
read: {
app: [sloFeatureId, 'kibana'],
catalogue: [sloFeatureId, 'observability'],
api: ['slo_read', 'rac'],
savedObject: {
all: [],
read: [SO_SLO_TYPE],
},
],
});
alerting: {
rule: {
read: [SLO_BURN_RATE_RULE_ID],
},
alert: {
read: [SLO_BURN_RATE_RULE_ID],
},
},
ui: ['read'],
},
},
});
registerRuleTypes(plugins.alerting, this.logger, ruleDataClient);
core.savedObjects.registerType(slo);
registerSloUsageCollector(plugins.usageCollection);
}
const ruleDataClient = ruleDataService.initializeIndex({
feature: sloFeatureId,
registrationContext: SLO_RULE_REGISTRATION_CONTEXT,
dataset: Dataset.alerts,
componentTemplateRefs: [ECS_COMPONENT_TEMPLATE_NAME],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(legacyExperimentalFieldMap, 'strict'),
},
],
});
registerRuleTypes(plugins.alerting, this.logger, ruleDataClient);
registerSloUsageCollector(plugins.usageCollection);
registerRoutes({
core,
@ -244,7 +240,7 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
ruleDataService,
},
logger: this.logger,
repository: getObservabilityServerRouteRepository(config),
repository: getObservabilityServerRouteRepository(),
});
/**

View file

@ -4,16 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ObservabilityConfig } from '..';
import { rulesRouteRepository } from './rules/route';
import { slosRouteRepository } from './slo/route';
export function getObservabilityServerRouteRepository(config: ObservabilityConfig) {
const isSloFeatureEnabled = config.unsafe.slo.enabled;
export function getObservabilityServerRouteRepository() {
const repository = {
...rulesRouteRepository,
...(isSloFeatureEnabled ? slosRouteRepository : {}),
...slosRouteRepository,
};
return repository;
}

View file

@ -43,6 +43,7 @@ export default function createRegisteredRuleTypeTests({ getService }: FtrProvide
'siem.thresholdRule',
'siem.newTermsRule',
'siem.notifications',
'slo.rules.burnRate',
'metrics.alert.anomaly',
'logs.alert.document.count',
'metrics.alert.inventory.threshold',

View file

@ -121,6 +121,7 @@ export default function ({ getService }: FtrProviderContext) {
'rulesSettings',
'uptime',
'siem',
'slo',
'securitySolutionCases',
'fleet',
'fleetv2',

View file

@ -24,6 +24,7 @@ export default function ({ getService }: FtrProviderContext) {
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
slo: ['all', 'read', 'minimal_all', 'minimal_read'],
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
actions: ['all', 'read', 'minimal_all', 'minimal_read'],

View file

@ -32,6 +32,7 @@ export default function ({ getService }: FtrProviderContext) {
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
generalCases: ['all', 'read', 'minimal_all', 'minimal_read'],
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'],
slo: ['all', 'read', 'minimal_all', 'minimal_read'],
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
@ -94,6 +95,7 @@ export default function ({ getService }: FtrProviderContext) {
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
slo: ['all', 'read', 'minimal_all', 'minimal_read'],
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
actions: ['all', 'read', 'minimal_all', 'minimal_read'],

View file

@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) {
'alerting:siem.queryRule',
'alerting:siem.savedQueryRule',
'alerting:siem.thresholdRule',
'alerting:slo.rules.burnRate',
'alerting:transform_health',
'alerting:xpack.ml.anomaly_detection_alert',
'alerting:xpack.ml.anomaly_detection_jobs_health',