[SLOs] Configuration inspect api and flyout (#173723)

## Summary

It will show all the associated configs at one place in json form,
configuration, ingest pipeline config, roll up transform and summary
transform config !!

Motivation is to understand things while onboarding devs to slo and
during normal development.



a22ad292-ba59-4145-989e-80803b6a1e3e
This commit is contained in:
Shahzad 2024-01-03 17:40:17 +01:00 committed by GitHub
parent 7c2b3f1301
commit 9dc9d8ff8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 437 additions and 4 deletions

View file

@ -50,6 +50,7 @@ const createSLOParamsSchema = t.type({
settings: optionalSettingsSchema,
tags: tagsSchema,
groupBy: allOrAnyString,
revision: t.number,
}),
]),
});

View file

@ -10,3 +10,6 @@ import { IngestPipelinesPlugin } from './plugin';
export function plugin() {
return new IngestPipelinesPlugin();
}
export { INGEST_PIPELINES_APP_LOCATOR, INGEST_PIPELINES_PAGES } from './locator';
export type { IngestPipelinesListParams } from './locator';

View file

@ -57,7 +57,8 @@
"unifiedSearch",
"stackAlerts",
"spaces",
"embeddable"
"embeddable",
"ingestPipelines"
],
"extraPublicDirs": [
"common"

View file

@ -104,6 +104,7 @@ export const renderApp = ({
>
<PluginContext.Provider
value={{
isDev,
config,
appMountParameters,
observabilityRuleTypeRegistry,

View file

@ -12,6 +12,7 @@ import type { ObservabilityRuleTypeRegistry } from '../../rules/create_observabi
import type { ConfigSchema } from '../../plugin';
export interface PluginContextValue {
isDev?: boolean;
config: ConfigSchema;
appMountParameters: AppMountParameters;
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;

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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type { FindSLOResponse, SLOResponse } from '@kbn/slo-schema';
import { QueryKey, useMutation } from '@tanstack/react-query';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { CreateSLOInput } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
type ServerError = IHttpFetchError<ResponseErrorBody>;
interface SLOInspectResponse {
slo: SLOResponse;
pipeline: Record<string, any>;
rollUpTransform: TransformPutTransformRequest;
summaryTransform: TransformPutTransformRequest;
temporaryDoc: Record<string, any>;
}
export function useInspectSlo() {
const { http } = useKibana().services;
return useMutation<
SLOInspectResponse,
ServerError,
{ slo: CreateSLOInput },
{ previousData?: FindSLOResponse; queryKey?: QueryKey }
>(
['inspectSlo'],
({ slo }) => {
const body = JSON.stringify(slo);
return http.post<SLOInspectResponse>(`/internal/api/observability/slos/_inspect`, { body });
},
{}
);
}

View file

@ -0,0 +1,26 @@
/*
* 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 { InPortal } from 'react-reverse-portal';
import { GetSLOResponse } from '@kbn/slo-schema';
import { CreateSLOForm } from '../../types';
import { SLOInspectWrapper } from './slo_inspect';
import { InspectSLOPortalNode } from '../../slo_edit';
export interface SloInspectPortalProps {
getValues: () => CreateSLOForm;
trigger: () => Promise<boolean>;
slo?: GetSLOResponse;
}
export function InspectSLOPortal(props: SloInspectPortalProps) {
return (
<InPortal node={InspectSLOPortalNode}>
<SLOInspectWrapper {...props} />
</InPortal>
);
}

View file

@ -0,0 +1,270 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React, { ReactNode, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import {
EuiFlyout,
EuiButton,
EuiCodeBlock,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutFooter,
EuiSpacer,
EuiFlyoutBody,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiAccordion,
EuiButtonIcon,
} from '@elastic/eui';
import {
INGEST_PIPELINES_APP_LOCATOR,
INGEST_PIPELINES_PAGES,
IngestPipelinesListParams,
} from '@kbn/ingest-pipelines-plugin/public';
import { SloInspectPortalProps } from './inspect_slo_portal';
import { ObservabilityPublicPluginsStart } from '../../../..';
import { useInspectSlo } from '../../../../hooks/slo/use_inspect_slo';
import { transformCreateSLOFormToCreateSLOInput } from '../../helpers/process_slo_form_values';
import { enableInspectEsQueries } from '../../../../../common';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
export function SLOInspectWrapper(props: SloInspectPortalProps) {
const {
services: { uiSettings },
} = useKibana();
const { isDev } = usePluginContext();
const isInspectorEnabled = uiSettings?.get<boolean>(enableInspectEsQueries);
return isDev || isInspectorEnabled ? <SLOInspect {...props} /> : null;
}
function SLOInspect({ getValues, trigger, slo }: SloInspectPortalProps) {
const { share, http } = useKibana<ObservabilityPublicPluginsStart>().services;
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const { mutateAsync: inspectSlo, data, isLoading } = useInspectSlo();
const { data: sloData } = useFetcher(async () => {
if (!isFlyoutVisible) {
return;
}
const isValid = await trigger();
if (!isValid) {
return;
}
const sloForm = transformCreateSLOFormToCreateSLOInput(getValues());
inspectSlo({ slo: { ...sloForm, id: slo?.id, revision: slo?.revision } });
return sloForm;
}, [isFlyoutVisible, trigger, getValues, inspectSlo, slo]);
const { data: pipeLineUrl } = useFetcher(async () => {
const ingestPipeLocator = share.url.locators.get<IngestPipelinesListParams>(
INGEST_PIPELINES_APP_LOCATOR
);
const ingestPipeLineId = data?.pipeline?.id;
return ingestPipeLocator?.getUrl({
pipelineId: ingestPipeLineId,
page: INGEST_PIPELINES_PAGES.LIST,
});
}, [data?.pipeline?.id, share.url.locators]);
const closeFlyout = () => {
setIsFlyoutVisible(false);
setIsInspecting(false);
};
const [isInspecting, setIsInspecting] = useState(false);
const onButtonClick = () => {
trigger().then((isValid) => {
if (isValid) {
setIsInspecting(() => !isInspecting);
setIsFlyoutVisible(() => !isFlyoutVisible);
}
});
};
let flyout;
if (isFlyoutVisible) {
flyout = (
<EuiFlyout ownFocus onClose={closeFlyout} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">{CONFIG_LABEL}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{isLoading && <LoadingState />}
<EuiSpacer size="m" />
{data && (
<>
<CodeBlockAccordion
id="slo"
label={i18n.translate(
'xpack.observability.sLOInspect.codeBlockAccordion.sloConfigurationLabel',
{ defaultMessage: 'SLO configuration' }
)}
json={data.slo}
/>
<EuiSpacer size="s" />
<CodeBlockAccordion
id="rollUpTransform"
label={i18n.translate(
'xpack.observability.sLOInspect.codeBlockAccordion.rollupTransformLabel',
{ defaultMessage: 'Rollup transform' }
)}
json={data.rollUpTransform}
extraAction={
<EuiButtonIcon
iconType="link"
data-test-subj="o11ySLOInspectDetailsButton"
href={http?.basePath.prepend('/app/management/data/transform')}
/>
}
/>
<EuiSpacer size="s" />
<CodeBlockAccordion
id="summaryTransform"
label={i18n.translate(
'xpack.observability.sLOInspect.codeBlockAccordion.summaryTransformLabel',
{ defaultMessage: 'Summary transform' }
)}
json={data.summaryTransform}
extraAction={
<EuiButtonIcon
iconType="link"
data-test-subj="o11ySLOInspectDetailsButton"
href={http?.basePath.prepend('/app/management/data/transform')}
/>
}
/>
<EuiSpacer size="s" />
<CodeBlockAccordion
id="pipeline"
label={i18n.translate(
'xpack.observability.sLOInspect.codeBlockAccordion.ingestPipelineLabel',
{ defaultMessage: 'SLO Ingest pipeline' }
)}
extraAction={
<EuiButtonIcon
iconType="link"
data-test-subj="o11ySLOInspectDetailsButton"
href={pipeLineUrl}
/>
}
json={data.pipeline}
/>
<EuiSpacer size="s" />
<CodeBlockAccordion
id="temporaryDoc"
label={i18n.translate(
'xpack.observability.sLOInspect.codeBlockAccordion.temporaryDocumentLabel',
{ defaultMessage: 'Temporary document' }
)}
json={data.temporaryDoc}
/>
</>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiButton
data-test-subj="syntheticsMonitorInspectCloseButton"
onClick={closeFlyout}
fill
>
{i18n.translate('xpack.observability.sLOInspect.closeButtonLabel', {
defaultMessage: 'Close',
})}
</EuiButton>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
return (
<>
<EuiToolTip
content={sloData ? VIEW_FORMATTED_CONFIG_LABEL : VALID_CONFIG_LABEL}
repositionOnScroll
>
<EuiButton
data-test-subj="syntheticsMonitorInspectShowFlyoutExampleButton"
onClick={onButtonClick}
iconType="inspect"
iconSide="left"
>
{SLO_INSPECT_LABEL}
</EuiButton>
</EuiToolTip>
{flyout}
</>
);
}
function CodeBlockAccordion({
id,
label,
json,
extraAction,
}: {
id: string;
label: string;
json: any;
extraAction?: ReactNode;
}) {
return (
<EuiAccordion
id={id}
extraAction={extraAction}
buttonContent={
<EuiTitle size="xs">
<h3>{label}</h3>
</EuiTitle>
}
>
<EuiCodeBlock language="json" fontSize="m" paddingSize="m" isCopyable={true}>
{JSON.stringify(json, null, 2)}
</EuiCodeBlock>
</EuiAccordion>
);
}
export function LoadingState() {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
const SLO_INSPECT_LABEL = i18n.translate('xpack.observability.sLOInspect.sLOInspectButtonLabel', {
defaultMessage: 'SLO Inspect',
});
const VIEW_FORMATTED_CONFIG_LABEL = i18n.translate(
'xpack.observability.slo.viewFormattedResourcesConfigsButtonLabel',
{ defaultMessage: 'View formatted resources configs for SLO' }
);
const VALID_CONFIG_LABEL = i18n.translate('xpack.observability.slo.formattedConfigLabel.valid', {
defaultMessage: 'Only valid form configurations can be inspected.',
});
const CONFIG_LABEL = i18n.translate('xpack.observability.monitorInspect.configLabel', {
defaultMessage: 'SLO Configurations',
});

View file

@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n';
import type { GetSLOResponse } from '@kbn/slo-schema';
import React, { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { InspectSLOPortal } from './common/inspect_slo_portal';
import { EquivalentApiRequest } from './common/equivalent_api_request';
import { BurnRateRuleFlyout } from '../../slos/components/common/burn_rate_rule_flyout';
import { paths } from '../../../../common/locators/paths';
@ -191,7 +192,7 @@ export function SloEditForm({ slo }: Props) {
defaultMessage: 'SLO burn rate alert rule',
})}
</strong>
</span>{' '}
</span>
<EuiIconTip
content={
'Selecting this will allow you to create a new alert rule for this SLO upon saving.'
@ -240,6 +241,7 @@ export function SloEditForm({ slo }: Props) {
/>
</EuiFlexGroup>
</EuiFlexGroup>
<InspectSLOPortal trigger={trigger} getValues={getValues} slo={slo} />
</FormProvider>
<BurnRateRuleFlyout

View file

@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { createHtmlPortalNode, OutPortal } from 'react-reverse-portal';
import { paths } from '../../../common/locators/paths';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
@ -21,6 +22,8 @@ import { FeedbackButton } from '../../components/slo/feedback_button/feedback_bu
import { SloEditForm } from './components/slo_edit_form';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';
export const InspectSLOPortalNode = createHtmlPortalNode();
export function SloEditPage() {
const {
application: { navigateToUrl },
@ -76,7 +79,7 @@ export function SloEditPage() {
: i18n.translate('xpack.observability.sloCreatePageTitle', {
defaultMessage: 'Create new SLO',
}),
rightSideItems: [<FeedbackButton />],
rightSideItems: [<FeedbackButton />, <OutPortal node={InspectSLOPortalNode} />],
bottomBorder: false,
}}
data-test-subj="slosEditPage"

View file

@ -114,6 +114,42 @@ const createSLORoute = createObservabilityServerRoute({
},
});
const inspectSLORoute = createObservabilityServerRoute({
endpoint: 'POST /internal/api/observability/slos/_inspect 2023-10-31',
options: {
tags: ['access:slo_write'],
access: 'public',
},
params: createSLOParamsSchema,
handler: async ({ context, params, logger, dependencies, request }) => {
await assertPlatinumLicense(context);
const spaceId =
(await dependencies.spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default';
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const repository = new KibanaSavedObjectsSLORepository(soClient);
const transformManager = new DefaultTransformManager(transformGenerators, esClient, logger);
const summaryTransformManager = new DefaultSummaryTransformManager(
new DefaultSummaryTransformGenerator(),
esClient,
logger
);
const createSLO = new CreateSLO(
esClient,
repository,
transformManager,
summaryTransformManager,
logger,
spaceId
);
return createSLO.inspect(params.body);
},
});
const updateSLORoute = createObservabilityServerRoute({
endpoint: 'PUT /api/observability/slos/{id} 2023-10-31',
options: {
@ -481,6 +517,7 @@ const getPreviewData = createObservabilityServerRoute({
export const sloRouteRepository = {
...createSLORoute,
...inspectSLORoute,
...deleteSLORoute,
...deleteSloInstancesRoute,
...disableSLORoute,

View file

@ -8,6 +8,7 @@
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { ALL_VALUE, CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
import { v4 as uuidv4 } from 'uuid';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
getSLOSummaryPipelineId,
getSLOSummaryTransformId,
@ -83,6 +84,32 @@ export class CreateSLO {
return this.toResponse(slo);
}
public inspect(params: CreateSLOParams): {
slo: CreateSLOParams;
pipeline: Record<string, any>;
rollUpTransform: TransformPutTransformRequest;
summaryTransform: TransformPutTransformRequest;
temporaryDoc: Record<string, any>;
} {
const slo = this.toSLO(params);
validateSLO(slo);
const rollUpTransform = this.transformManager.inspect(slo);
const pipeline = getSLOSummaryPipelineTemplate(slo, this.spaceId);
const summaryTransform = this.summaryTransformManager.inspect(slo);
const temporaryDoc = createTempSummaryDocument(slo, this.spaceId);
return {
pipeline,
temporaryDoc,
summaryTransform,
rollUpTransform,
slo,
};
}
private toSLO(params: CreateSLOParams): SLO {
const now = new Date();
return {
@ -92,7 +119,7 @@ export class CreateSLO {
syncDelay: params.settings?.syncDelay ?? new Duration(1, DurationUnit.Minute),
frequency: params.settings?.frequency ?? new Duration(1, DurationUnit.Minute),
},
revision: 1,
revision: params.revision ?? 1,
enabled: true,
tags: params.tags ?? [],
createdAt: now,

View file

@ -25,6 +25,7 @@ const createTransformManagerMock = (): jest.Mocked<TransformManager> => {
uninstall: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
inspect: jest.fn(),
};
};
@ -35,6 +36,7 @@ const createSummaryTransformManagerMock = (): jest.Mocked<TransformManager> => {
uninstall: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
inspect: jest.fn(),
};
};

View file

@ -7,6 +7,7 @@
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import { SLO } from '../../domain/models';
import { SecurityException } from '../../errors';
import { retryTransientEsErrors } from '../../utils/retry';
@ -40,6 +41,10 @@ export class DefaultSummaryTransformManager implements TransformManager {
return transformParams.transform_id;
}
inspect(slo: SLO): TransformPutTransformRequest {
return this.generator.generate(slo);
}
async preview(transformId: string): Promise<void> {
try {
await retryTransientEsErrors(

View file

@ -7,6 +7,7 @@
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SLO, IndicatorTypes } from '../../domain/models';
import { SecurityException } from '../../errors';
import { retryTransientEsErrors } from '../../utils/retry';
@ -16,6 +17,7 @@ type TransformId = string;
export interface TransformManager {
install(slo: SLO): Promise<TransformId>;
inspect(slo: SLO): TransformPutTransformRequest;
preview(transformId: TransformId): Promise<void>;
start(transformId: TransformId): Promise<void>;
stop(transformId: TransformId): Promise<void>;
@ -53,6 +55,16 @@ export class DefaultTransformManager implements TransformManager {
return transformParams.transform_id;
}
inspect(slo: SLO): TransformPutTransformRequest {
const generator = this.generators[slo.indicator.type];
if (!generator) {
this.logger.error(`No transform generator found for indicator type [${slo.indicator.type}]`);
throw new Error(`Unsupported indicator type [${slo.indicator.type}]`);
}
return generator.getTransformParams(slo);
}
async preview(transformId: string): Promise<void> {
try {
await retryTransientEsErrors(

View file

@ -100,6 +100,7 @@
"@kbn/presentation-util-plugin",
"@kbn/task-manager-plugin",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/ingest-pipelines-plugin",
"@kbn/core-saved-objects-api-server-mocks"
],
"exclude": [