[SLOs] Annotations Initial phase (#184325)

## Summary

Ability to add annotations on SLO charts !!

Handles initial phase of https://github.com/elastic/kibana/issues/182429

Shows custom annotations added to SLO charts

<img width="1720" alt="image"
src="15623567-8b1f-44e2-a551-6cb163e84354">

### Adding annotation

They can be added via UI or API by right clicking on a chart and
clicking an action
<img width="1473" alt="image"
src="07422b33-48c7-4245-bbda-4ab58c279229">

Alternatively they can be added via brushing event on the chart while
holding command key.

it will open a flyout which can be used to add an annotation

<img width="1474" alt="image"
src="053fb13d-1b70-4ef3-97db-bb4dd8bfb858">


Clicking on an annotation will open editing in a flyout !!

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dominique Clarke <dominique.clarke@elastic.co>
This commit is contained in:
Shahzad 2024-07-16 07:28:02 +02:00 committed by GitHub
parent 4f3e94059f
commit 642216820e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 4419 additions and 257 deletions

View file

@ -30,6 +30,7 @@ export {
defaultAnnotationColor,
defaultAnnotationRangeColor,
defaultAnnotationLabel,
defaultRangeAnnotationLabel,
getDefaultManualAnnotation,
getDefaultQueryAnnotation,
createCopiedAnnotation,

View file

@ -57,6 +57,13 @@ export const defaultAnnotationLabel = i18n.translate(
}
);
export const defaultRangeAnnotationLabel = i18n.translate(
'eventAnnotationCommon.manualAnnotation.defaultRangeAnnotationLabel',
{
defaultMessage: 'Event range',
}
);
export const getDefaultManualAnnotation = (
id: string,
timestamp: string

View file

@ -110,7 +110,7 @@ pageLoadAssetSize:
navigation: 37269
newsfeed: 42228
noDataPage: 5000
observability: 76678
observability: 118191
observabilityAIAssistant: 58230
observabilityAIAssistantApp: 27680
observabilityAiAssistantManagement: 19279

View file

@ -8,6 +8,8 @@
import * as t from 'io-ts';
import { either } from 'fp-ts/lib/Either';
export const DEFAULT_ANNOTATION_INDEX = 'observability-annotations';
/**
* Checks whether a string is a valid ISO timestamp,
* but doesn't convert it into a Date object when decoding.
@ -25,45 +27,87 @@ const dateAsStringRt = new t.Type<string, string, unknown>(
t.identity
);
export const rectFill = t.union([t.literal('inside'), t.literal('outside')]);
export const createAnnotationRt = t.intersection([
t.type({
annotation: t.type({
annotation: t.partial({
title: t.string,
type: t.string,
style: t.partial({
icon: t.string,
color: t.string,
line: t.partial({
width: t.number,
style: t.union([t.literal('dashed'), t.literal('solid'), t.literal('dotted')]),
iconPosition: t.union([t.literal('top'), t.literal('bottom')]),
textDecoration: t.union([t.literal('none'), t.literal('name')]),
}),
rect: t.partial({
fill: rectFill,
}),
}),
}),
'@timestamp': dateAsStringRt,
message: t.string,
}),
t.partial({
event: t.intersection([
t.type({
start: dateAsStringRt,
}),
t.partial({
end: dateAsStringRt,
}),
]),
tags: t.array(t.string),
service: t.partial({
name: t.string,
environment: t.string,
version: t.string,
}),
monitor: t.partial({
id: t.string,
}),
slo: t.intersection([
t.type({
id: t.string,
}),
t.partial({
instanceId: t.string,
}),
]),
host: t.partial({
name: t.string,
}),
}),
]);
export const deleteAnnotationRt = t.type({
id: t.string,
});
export const deleteAnnotationRt = t.type({ id: t.string });
export const getAnnotationByIdRt = t.type({
id: t.string,
});
export interface Annotation {
annotation: {
type: string;
};
tags?: string[];
message: string;
service?: {
name?: string;
environment?: string;
version?: string;
};
event: {
created: string;
};
'@timestamp': string;
}
export const findAnnotationRt = t.partial({
query: t.string,
start: t.string,
end: t.string,
sloId: t.string,
sloInstanceId: t.string,
serviceName: t.string,
});
export const updateAnnotationRt = t.intersection([
t.type({
id: t.string,
}),
createAnnotationRt,
]);
export type CreateAnnotationParams = t.TypeOf<typeof createAnnotationRt>;
export type DeleteAnnotationParams = t.TypeOf<typeof deleteAnnotationRt>;
export type GetByIdAnnotationParams = t.TypeOf<typeof getAnnotationByIdRt>;
export type FindAnnotationParams = t.TypeOf<typeof findAnnotationRt>;
export type Annotation = t.TypeOf<typeof updateAnnotationRt>;

View file

@ -16,6 +16,7 @@ export const RULES_PATH = '/alerts/rules' as const;
export const RULES_LOGS_PATH = '/alerts/rules/logs' as const;
export const RULE_DETAIL_PATH = '/alerts/rules/:ruleId' as const;
export const CASES_PATH = '/cases' as const;
export const ANNOTATIONS_PATH = '/annotations' as const;
export const SETTINGS_PATH = '/slos/settings' as const;
// // SLOs have been moved to its own app (slo). Keeping around for redirecting purposes.
@ -30,6 +31,7 @@ export const SLO_DETAIL_PATH = '/:sloId' as const;
export const paths = {
observability: {
alerts: `${OBSERVABILITY_BASE_PATH}${ALERTS_PATH}`,
annotations: `${OBSERVABILITY_BASE_PATH}${ANNOTATIONS_PATH}`,
alertDetails: (alertId: string) =>
`${OBSERVABILITY_BASE_PATH}${ALERTS_PATH}/${encodeURIComponent(alertId)}`,
rules: `${OBSERVABILITY_BASE_PATH}${RULES_PATH}`,

View file

@ -0,0 +1,128 @@
/*
* 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 {
EuiColorPicker,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import { IconSelect, LineStyleSettings } from '@kbn/visualization-ui-components';
import React from 'react';
import { Select } from './components/forward_refs';
import { TextDecoration } from './components/text_decoration';
import { Annotation } from '../../../common/annotations';
import { FillOptions } from './components/fill_option';
import { iconsSet } from './icon_set';
export function AnnotationAppearance() {
const { control, watch } = useFormContext<Annotation>();
const eventEnd = watch('event.end');
return (
<>
<EuiTitle size="xxs">
<h3>
{i18n.translate('xpack.observability.annotationForm.euiFormRow.appearance', {
defaultMessage: 'Appearance',
})}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{eventEnd ? (
<FillOptions />
) : (
<>
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.markerIconLabel', {
defaultMessage: 'Icon decoration',
})}
display="columnCompressed"
fullWidth
>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<Controller
name="annotation.style.icon"
control={control}
render={({ field }) => (
<IconSelect
onChange={field.onChange}
customIconSet={iconsSet}
value={field.value}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<Controller
name="annotation.style.line.iconPosition"
control={control}
render={({ field }) => (
<Select
compressed
data-test-subj="o11yAnnotationAppearanceSelect"
{...field}
options={[
{ value: 'top', text: 'Top' },
{ value: 'bottom', text: 'Bottom' },
]}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<TextDecoration />
<Controller
name="annotation.style.line"
control={control}
render={({ field, fieldState }) => (
<LineStyleSettings
currentConfig={{
lineStyle: field.value?.style,
lineWidth: field.value?.width,
}}
setConfig={(newVal) => {
field.onChange({
...field.value,
style: newVal.lineStyle,
width: newVal.lineWidth,
});
}}
idPrefix="o11yAnnotations"
/>
)}
/>
</>
)}
<EuiFormRow
label={i18n.translate(
'xpack.observability.annotationForm.euiFormRow.lineStrokeColorLabel',
{ defaultMessage: 'Color' }
)}
display="columnCompressed"
fullWidth
>
<Controller
name="annotation.style.color"
control={control}
render={({ field: { ref, ...field } }) => (
<EuiColorPicker {...field} color={field.value} compressed showAlpha={true} />
)}
/>
</EuiFormRow>
</>
);
}

View file

@ -0,0 +1,144 @@
/*
* 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 { EuiForm, EuiFormRow, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { CreateAnnotationForm } from './components/create_annotation';
import { AnnotationApplyTo } from './components/annotation_apply_to';
import { Annotation } from '../../../common/annotations';
import { ComboBox, FieldText, Switch, TextArea } from './components/forward_refs';
import { AnnotationRange } from './components/annotation_range';
import { AnnotationAppearance } from './annotation_apearance';
export function AnnotationForm({ editAnnotation }: { editAnnotation?: Annotation | null }) {
const { control, formState, watch, trigger } = useFormContext<CreateAnnotationForm>();
const timestampStart = watch('@timestamp');
return (
<EuiForm id="annotationForm" component="form">
<AnnotationRange />
<EuiSpacer size="s" />
<Controller
name="event.end"
control={control}
render={({ field: { value, ...field } }) => (
<Switch
{...field}
label={i18n.translate(
'xpack.observability.annotationForm.euiFormRow.applyAsRangeLabel',
{
defaultMessage: 'Apply as range',
}
)}
checked={Boolean(value)}
onChange={(evt) => {
field.onChange(evt.target.checked ? timestampStart : null);
}}
compressed
/>
)}
/>
<EuiHorizontalRule margin="s" />
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.titleLabel', {
defaultMessage: 'Title',
})}
display="columnCompressed"
fullWidth
error={formState.errors.message?.message}
isInvalid={Boolean(formState.errors.message?.message)}
>
<Controller
defaultValue=""
name="annotation.title"
control={control}
rules={{
required: 'title is required',
}}
render={({ field, fieldState }) => (
<FieldText
{...field}
isInvalid={fieldState.invalid}
compressed
data-test-subj="annotationTitle"
onBlur={() => {
field.onBlur();
// this is done to avoid too many re-renders, watch on name is expensive
trigger();
}}
/>
)}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.descriptionLabel', {
defaultMessage: 'Description',
})}
display="columnCompressed"
fullWidth
error={formState.errors.message?.message}
isInvalid={Boolean(formState.errors.message?.message)}
>
<Controller
defaultValue=""
name="message"
control={control}
render={({ field, fieldState }) => (
<TextArea
{...field}
rows={3}
isInvalid={fieldState.invalid}
compressed
data-test-subj="annotationMessage"
onBlur={() => {
field.onBlur();
// this is done to avoid too many re-renders, watch on name is expensive
trigger();
}}
/>
)}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.tagsLabel', {
defaultMessage: 'Tags',
})}
display="columnCompressed"
fullWidth
>
<Controller
defaultValue={[]}
name="tags"
control={control}
render={({ field }) => (
<ComboBox
{...field}
options={field.value?.map((tag) => ({ label: tag }))}
selectedOptions={field.value?.map((tag) => ({ label: tag }))}
onChange={(val) => {
field.onChange(val.map((option) => option.label));
}}
onCreateOption={(searchValue) => {
field.onChange([...(field.value ?? []), searchValue]);
}}
isClearable={true}
compressed
data-test-subj="annotationTags"
/>
)}
/>
</EuiFormRow>
<EuiHorizontalRule margin="s" />
<AnnotationAppearance />
<EuiHorizontalRule margin="s" />
<AnnotationApplyTo editAnnotation={editAnnotation} />
</EuiForm>
);
}

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { SLOApplyTo } from './slo_apply_to';
import { Annotation } from '../../../../common/annotations';
export function AnnotationApplyTo({ editAnnotation }: { editAnnotation?: Annotation | null }) {
return (
<>
<EuiTitle size="xxs">
<h3>
{i18n.translate('xpack.observability.annotationForm.euiFormRow.applyTo', {
defaultMessage: 'Apply to',
})}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<SLOApplyTo editAnnotation={editAnnotation} />
</>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 { EuiIcon, useEuiTheme } from '@elastic/eui';
import { annotationsIconSet } from '@kbn/event-annotation-components';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
export function AnnotationIcon({
annotation,
}: {
annotation: Annotation | CreateAnnotationParams;
}) {
const eventEnd = annotation.event?.end;
const { euiTheme } = useEuiTheme();
const annotationStyle = annotation.annotation?.style;
const iconValue = annotation.annotation.style?.icon;
const color = annotationStyle?.color ?? euiTheme.colors.accent;
return (
<EuiIcon
type={
eventEnd
? 'stopFilled'
: (annotationsIconSet.find((icon) => icon.value === iconValue)?.icon as IconType) ??
(iconValue as IconType)
}
color={color}
/>
);
}

View file

@ -0,0 +1,136 @@
/*
* 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 { EuiFormRow, EuiFormControlLayout, EuiFormLabel, EuiDatePicker } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import React from 'react';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { CreateAnnotationForm } from './create_annotation';
const getHelpfulDateFormat = (dateFormat: string) => {
if (dateFormat.endsWith('HH:mm:ss.SSS')) {
// we don't want microseconds in the date picker
return dateFormat.replace(':ss.SSS', ':ss');
}
return dateFormat;
};
export function AnnotationRange() {
const { control, watch } = useFormContext<CreateAnnotationForm>();
const eventEnd = watch('event.end');
const dateFormatDefault = useUiSetting<string>('dateFormat');
const dateFormat = getHelpfulDateFormat(dateFormatDefault);
if (eventEnd) {
return (
<>
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.date', {
defaultMessage: 'Timestamp',
})}
display="rowCompressed"
fullWidth
>
<EuiFormControlLayout
fullWidth
compressed
prepend={
<EuiFormLabel>
{i18n.translate('xpack.observability.annotationRange.fromFormLabelLabel', {
defaultMessage: 'From',
})}
</EuiFormLabel>
}
>
<Controller
name="@timestamp"
control={control}
rules={{
required: true,
}}
render={({ field }) => {
const { value, ref, ...rest } = field;
return (
<EuiDatePicker
showTimeSelect
selected={field.value}
compressed
dateFormat={dateFormat}
{...rest}
/>
);
}}
/>
</EuiFormControlLayout>
</EuiFormRow>
<EuiFormRow display="rowCompressed" fullWidth>
<EuiFormControlLayout
fullWidth
compressed
prepend={
<EuiFormLabel>
{i18n.translate('xpack.observability.annotationRange.toFormLabelLabel', {
defaultMessage: 'To',
})}
</EuiFormLabel>
}
>
<Controller
name="event.end"
control={control}
rules={{
required: true,
}}
render={({ field }) => {
const { value, ref, ...rest } = field;
return (
<EuiDatePicker
showTimeSelect
selected={field.value}
compressed
dateFormat={dateFormat}
{...rest}
/>
);
}}
/>
</EuiFormControlLayout>
</EuiFormRow>
</>
);
}
return (
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.annotationDate', {
defaultMessage: 'Annotation date',
})}
>
<Controller
name="@timestamp"
control={control}
rules={{
required: true,
}}
render={({ field }) => {
const { value, ref, ...rest } = field;
return (
<EuiDatePicker
showTimeSelect
selected={field.value}
compressed
dateFormat={dateFormat}
{...rest}
/>
);
}}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,88 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiDescriptionList,
} from '@elastic/eui';
import { TagsList } from '@kbn/observability-shared-plugin/public';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { TimestampRangeLabel } from './timestamp_range_label';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
import { AnnotationIcon } from './annotation_icon';
import './annotations.scss';
export function AnnotationTooltip({
annotation,
}: {
annotation: Annotation | CreateAnnotationParams;
}) {
const listItems = [
{
title: i18n.translate('xpack.observability.annotationTooltip.title', {
defaultMessage: 'Title',
}),
description: annotation.annotation.title ?? '--',
},
{
title: i18n.translate('xpack.observability.annotationTooltip.tags', {
defaultMessage: 'Tags',
}),
description: <TagsList tags={annotation.tags} />,
},
{
title: i18n.translate('xpack.observability.annotationTooltip.description', {
defaultMessage: 'Description',
}),
description: annotation.message ?? '--',
},
];
if (annotation.slo?.id) {
listItems.push({
title: i18n.translate('xpack.observability.annotationTooltip.slo', {
defaultMessage: 'SLO',
}),
description: annotation.slo?.id,
});
}
if (annotation.service) {
listItems.push({
title: i18n.translate('xpack.observability.annotationTooltip.service', {
defaultMessage: 'Service',
}),
description: annotation.service.name ?? '--',
});
}
return (
<EuiPanel color="plain" hasShadow={false} hasBorder={false} paddingSize="m" borderRadius="none">
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<AnnotationIcon annotation={annotation} />
</EuiFlexItem>
<EuiFlexItem>
<TimestampRangeLabel annotation={annotation} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiDescriptionList
compressed
listItems={listItems}
type="column"
columnWidths={[1, 3]} // Same as [25, 75]
style={{ maxInlineSize: '400px' }}
/>
</EuiPanel>
);
}

View file

@ -0,0 +1,3 @@
.echAnnotation {
max-width: 500px;
}

View file

@ -0,0 +1,59 @@
/*
* 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 { EuiButton, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { AnnotationsPermissions } from '../../hooks/use_annotation_permissions';
import { Annotation } from '../../../../../common/annotations';
export function DeleteAnnotations({
selection,
isLoading,
permissions,
setIsDeleteModalVisible,
}: {
selection: Annotation[];
isLoading?: boolean;
permissions?: AnnotationsPermissions;
setIsDeleteModalVisible: (isVisible: boolean) => void;
}) {
if (selection.length === 0) {
return <> </>;
}
const btn = (
<EuiButton
data-test-subj="o11yRenderToolsLeftUsersButton"
color="danger"
iconType="trash"
onClick={() => {
setIsDeleteModalVisible(true);
}}
isLoading={isLoading}
isDisabled={selection.length === 0 || !permissions?.write}
>
{i18n.translate('xpack.observability.renderToolsLeft.deleteButtonLabel', {
defaultMessage: 'Delete {no} annotations',
values: { no: selection.length },
})}
</EuiButton>
);
if (permissions?.write) {
return <>{btn}</>;
}
return (
<EuiToolTip
content={i18n.translate('xpack.observability.renderToolsLeft.deleteButtonDisabledTooltip', {
defaultMessage: 'You do not have permission to delete annotations',
})}
>
{btn}
</EuiToolTip>
);
}

View file

@ -0,0 +1,67 @@
/*
* 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 { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Annotation } from '../../../../../common/annotations';
export function DeleteAnnotationsModal({
isDeleteModalVisible,
onDelete,
setSelection,
setIsDeleteModalVisible,
selection,
}: {
selection: Annotation[];
isDeleteModalVisible: boolean;
setSelection: (selection: Annotation[]) => void;
onDelete: () => void;
setIsDeleteModalVisible: (isVisible: boolean) => void;
}) {
if (!isDeleteModalVisible) {
return <> </>;
}
return (
<EuiConfirmModal
title={i18n.translate(
'xpack.observability.deleteAnnotations.euiConfirmModal.deleteAnnotationLabel',
{ defaultMessage: 'Delete annotation' }
)}
onCancel={() => {
setIsDeleteModalVisible(false);
setSelection([]);
}}
onConfirm={onDelete}
cancelButtonText={i18n.translate(
'xpack.observability.deleteAnnotations.euiConfirmModal.cancelButtonLabel',
{ defaultMessage: 'Cancel' }
)}
confirmButtonText={i18n.translate(
'xpack.observability.deleteAnnotations.euiConfirmModal.deleteButtonLabel',
{ defaultMessage: 'Delete' }
)}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>
{i18n.translate(
'xpack.observability.deleteAnnotations.euiConfirmModal.deleteAnnotationDescription',
{
defaultMessage: 'Are you sure you want to delete "{names}" annotation?',
values: {
names: selection
.map((annotation) => annotation.annotation.title ?? annotation.message)
.join(', '),
},
}
)}
</p>
</EuiConfirmModal>
);
}

View file

@ -0,0 +1,114 @@
/*
* 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 { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import React, { useState } from 'react';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import { Annotation } from '../../../../../common/annotations';
import { Suggestion, useFetchApmSuggestions } from '../../hooks/use_fetch_apm_suggestions';
interface Option {
label: string;
value: string;
}
export interface Props {
allowAllOption?: boolean;
dataTestSubj: string;
fieldName: string;
label: string;
name: FieldPath<Annotation>;
placeholder: string;
}
export function FieldSelector({
allowAllOption = true,
dataTestSubj,
fieldName,
label,
name,
placeholder,
}: Props) {
const { control, watch, getFieldState } = useFormContext<Annotation>();
const serviceName = watch('service.name');
const [search, setSearch] = useState<string>('');
const { suggestions, isLoading } = useFetchApmSuggestions({
fieldName,
search,
serviceName,
});
const debouncedSearch = debounce((value) => setSearch(value), 200);
const options = (
allowAllOption
? [
{
value: ALL_VALUE,
label: i18n.translate('xpack.observability.sloEdit.fieldSelector.all', {
defaultMessage: 'All',
}),
},
]
: []
).concat(createOptions(suggestions));
return (
<EuiFlexItem>
<EuiFormRow label={label} display="columnCompressed" isInvalid={getFieldState(name).invalid}>
<Controller
defaultValue=""
name={name}
control={control}
render={({ field, fieldState }) => (
<EuiComboBox
{...field}
aria-label={placeholder}
async
compressed
data-test-subj={dataTestSubj}
isClearable
isInvalid={fieldState.invalid}
isLoading={isLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
onSearchChange={(value: string) => debouncedSearch(value)}
options={options}
placeholder={placeholder}
selectedOptions={
!!field.value && typeof field.value === 'string'
? [
{
value: field.value,
label: field.value,
'data-test-subj': `${dataTestSubj}SelectedValue`,
},
]
: []
}
singleSelection
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
);
}
function createOptions(suggestions: Suggestion[]): Option[] {
return suggestions
.map((suggestion) => ({ label: suggestion, value: suggestion }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}

View file

@ -0,0 +1,175 @@
/*
* 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, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutResizable,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
} from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { Moment } from 'moment';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
import { AnnotationForm } from '../annotation_form';
export type CreateAnnotationForm = Omit<CreateAnnotationParams, '@timestamp' | 'event'> & {
'@timestamp': Moment;
event: {
start?: Moment | null;
end?: Moment | null;
};
};
export interface CreateAnnotationProps {
isLoading: boolean;
onSave: () => void;
onCancel: () => void;
isCreateAnnotationsOpen: boolean;
editAnnotation?: Annotation | null;
updateAnnotation: (data: { annotation: Annotation }) => void;
createAnnotation: (data: { annotation: CreateAnnotationParams }) => void;
deleteAnnotation: (data: { annotations: Annotation[] }) => void;
}
export function CreateAnnotation({
onSave,
onCancel,
isLoading,
editAnnotation,
createAnnotation,
deleteAnnotation,
updateAnnotation,
isCreateAnnotationsOpen,
}: CreateAnnotationProps) {
const { trigger, getValues } = useFormContext<CreateAnnotationForm>();
const onSubmit = useCallback(async () => {
const isValid = await trigger();
if (!isValid) return;
const values = getValues();
const timestamp = values['@timestamp'].toISOString();
if (editAnnotation?.id) {
await updateAnnotation({
annotation: {
...values,
id: editAnnotation.id,
'@timestamp': timestamp,
event: {
start: timestamp,
end: values.event?.end?.toISOString(),
},
},
});
} else {
await createAnnotation({
annotation: {
...values,
'@timestamp': timestamp,
event: {
start: timestamp,
end: values.event?.end?.toISOString(),
},
},
});
}
onSave();
}, [trigger, getValues, editAnnotation?.id, onSave, updateAnnotation, createAnnotation]);
const onDelete = async () => {
if (editAnnotation?.id) {
await deleteAnnotation({ annotations: [editAnnotation] });
onSave();
}
};
let flyout;
if (isCreateAnnotationsOpen) {
flyout = (
<EuiFlyoutResizable onClose={onCancel} type="push" size="s" maxWidth={700}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>
{editAnnotation
? i18n.translate(
'xpack.observability.createAnnotation.editAnnotationModalHeaderTitleLabel',
{
defaultMessage: 'Update annotation',
}
)
: i18n.translate(
'xpack.observability.createAnnotation.addAnnotationModalHeaderTitleLabel',
{ defaultMessage: 'Create annotation' }
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AnnotationForm editAnnotation={editAnnotation} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexStart">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="observabilitySolutionCancelButton"
onClick={onCancel}
isLoading={isLoading}
>
{i18n.translate('xpack.observability.createAnnotation.closeButtonEmptyLabel', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem />
{editAnnotation?.id && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="annotationDeleteButton"
type="submit"
onClick={() => onDelete()}
isLoading={isLoading}
color="danger"
>
{i18n.translate('xpack.observability.createAnnotation.deleteButtonLabel', {
defaultMessage: 'Delete',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="annotationSaveButton"
type="submit"
onClick={() => onSubmit()}
isLoading={isLoading}
fill
>
{editAnnotation?.id
? i18n.translate('xpack.observability.createAnnotation.updateButtonLabel', {
defaultMessage: 'Update',
})
: i18n.translate('xpack.observability.createAnnotation.saveButtonLabel', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyoutResizable>
);
}
return <>{flyout}</>;
}
// eslint-disable-next-line import/no-default-export
export default CreateAnnotation;

View file

@ -0,0 +1,64 @@
/*
* 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 { EuiButtonGroup, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateAnnotationParams } from '../../../../common/annotations';
export function FillOptions() {
const { control } = useFormContext<CreateAnnotationParams>();
return (
<>
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.fillOptions.legend', {
defaultMessage: 'Fill',
})}
display="columnCompressed"
fullWidth
>
<Controller
defaultValue="inside"
name="annotation.style.rect.fill"
control={control}
render={({ field }) => (
<EuiButtonGroup
buttonSize="compressed"
isFullWidth={true}
id="fillOptions"
idSelected={field.value as string}
onChange={(id) => {
field.onChange(id);
}}
options={options}
legend={i18n.translate('xpack.observability.annotationForm.fillOptions.legend', {
defaultMessage: 'Fill',
})}
/>
)}
/>
</EuiFormRow>
</>
);
}
const options = [
{
id: 'inside',
label: i18n.translate('xpack.observability.annotationForm.fillOptions.inside', {
defaultMessage: 'Inside',
}),
},
{
id: 'outside',
label: i18n.translate('xpack.observability.annotationForm.fillOptions.outside', {
defaultMessage: 'Outside',
}),
},
];

View file

@ -0,0 +1,46 @@
/*
* 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 {
EuiComboBox,
EuiComboBoxProps,
EuiSwitch,
EuiSwitchProps,
EuiTextArea,
EuiTextAreaProps,
EuiFieldTextProps,
EuiFieldText,
EuiFieldNumber,
EuiSelectProps,
EuiSelect,
EuiFieldNumberProps,
} from '@elastic/eui';
export const FieldText = React.forwardRef<HTMLInputElement, EuiFieldTextProps>((props, ref) => (
<EuiFieldText data-test-subj={props['data-test-subj']} {...props} inputRef={ref} />
));
export const NumberField = React.forwardRef<HTMLInputElement, EuiFieldNumberProps>((props, ref) => (
<EuiFieldNumber data-test-subj={props['data-test-subj']} {...props} inputRef={ref} />
));
export const TextArea = React.forwardRef<HTMLTextAreaElement, EuiTextAreaProps>((props, ref) => (
<EuiTextArea data-test-subj="o11yTextAreaTextArea" {...props} inputRef={ref} />
));
export const ComboBox = React.forwardRef<unknown, EuiComboBoxProps<string>>((props, _ref) => (
<EuiComboBox {...props} />
));
export const Switch = React.forwardRef<unknown, EuiSwitchProps>((props, _ref) => (
<EuiSwitch {...props} />
));
export const Select = React.forwardRef<HTMLSelectElement, EuiSelectProps>((props, ref) => (
<EuiSelect data-test-subj="o11ySelectSelect" {...props} inputRef={ref} />
));

View file

@ -0,0 +1,31 @@
/*
* 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, { lazy, Suspense } from 'react';
import type { ObservabilityAnnotationsProps } from './observability_annotation';
const ObservabilityAnnotationsLazy = lazy(() => import('./observability_annotation'));
export function ObservabilityAnnotations(props: ObservabilityAnnotationsProps) {
return (
<Suspense fallback={null}>
<ObservabilityAnnotationsLazy {...props} />
</Suspense>
);
}
import type { CreateAnnotationProps } from './create_annotation';
const CreateAnnotationLazy = lazy(() => import('./create_annotation'));
export function CreateAnnotation(props: CreateAnnotationProps) {
return (
<Suspense fallback={null}>
<CreateAnnotationLazy {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,95 @@
/*
* 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 moment from 'moment';
import { AnnotationDomainType, LineAnnotation } from '@elastic/charts';
import { EuiText, useEuiTheme } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { AnnotationIcon } from './annotation_icon';
import { AnnotationTooltip } from './annotation_tooltip';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
export function NewLineAnnotation({
slo,
isCreateOpen,
}: {
slo?: SLOWithSummaryResponse;
isCreateOpen: boolean;
}) {
const { watch, getValues } = useFormContext<CreateAnnotationParams>();
const eventEnd = watch('event.end');
if (eventEnd || !isCreateOpen) {
return null;
}
const values = getValues();
const annotationStyle = watch('annotation.style');
const annotationType = watch('annotation.type');
return (
<ObsLineAnnotation
annotation={{
...values,
annotation: {
...values.annotation,
style: annotationStyle,
type: annotationType,
},
...(slo ? { slo: { id: slo.id, instanceId: slo.instanceId } } : {}),
}}
/>
);
}
export function ObsLineAnnotation({
annotation,
}: {
annotation: CreateAnnotationParams | Annotation;
}) {
const timestamp = annotation['@timestamp'];
const message = annotation.message;
const { euiTheme } = useEuiTheme();
const line = annotation.annotation.style?.line;
return (
<LineAnnotation
id={'id' in annotation ? annotation.id : `${timestamp}${message}`}
domainType={AnnotationDomainType.XDomain}
dataValues={[
{
dataValue: moment(timestamp).valueOf(),
details: message,
header: annotation.message,
},
]}
style={{
line: {
strokeWidth: line?.width ?? 2,
stroke: annotation?.annotation.style?.color ?? euiTheme.colors.warning,
opacity: 1,
...(line?.style === 'dashed' && {
dash: [(line?.width ?? 2) * 3, line?.width ?? 2],
}),
...(line?.style === 'dotted' && {
dash: [line?.width ?? 2, line?.width ?? 2],
}),
},
}}
marker={
<span>
<AnnotationIcon annotation={annotation} />
</span>
}
markerBody={<EuiText>{annotation.annotation?.title ?? annotation.message}</EuiText>}
markerPosition={annotation.annotation.style?.line?.iconPosition ?? 'top'}
customTooltip={() => <AnnotationTooltip annotation={annotation} />}
/>
);
}

View file

@ -0,0 +1,82 @@
/*
* 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 { RectAnnotation } from '@elastic/charts';
import React from 'react';
import { useEuiTheme } from '@elastic/eui';
import moment from 'moment';
import { useFormContext } from 'react-hook-form';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { AnnotationTooltip } from './annotation_tooltip';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
export function NewRectAnnotation({
slo,
isCreateOpen,
}: {
isCreateOpen: boolean;
slo?: SLOWithSummaryResponse;
}) {
const { watch, getValues } = useFormContext<CreateAnnotationParams>();
const timestamp = watch('@timestamp');
const eventEnd = watch('event.end');
if (!timestamp || !eventEnd || !isCreateOpen) {
return null;
}
const values = getValues();
const annotationStyle = watch('annotation.style');
const annotationType = watch('annotation.type');
return (
<ObsRectAnnotation
annotation={{
...values,
annotation: {
...values.annotation,
style: annotationStyle,
type: annotationType,
},
...(slo ? { slo: { id: slo.id, instanceId: slo.instanceId } } : {}),
}}
/>
);
}
export function ObsRectAnnotation({
annotation,
}: {
annotation: Annotation | CreateAnnotationParams;
}) {
const message = annotation.message;
const timestamp = annotation['@timestamp'];
const timestampEnd = annotation.event?.end;
const { euiTheme } = useEuiTheme();
const annotationStyle = annotation.annotation?.style;
const color = annotationStyle?.color ?? euiTheme.colors.warning;
return (
<RectAnnotation
dataValues={[
{
coordinates: {
x0: moment(timestamp).valueOf(),
x1: moment(timestampEnd).valueOf(),
},
details: message,
},
]}
id={'id' in annotation ? annotation.id : `${timestamp}${message}`}
style={{ fill: color, opacity: 1 }}
outside={annotationStyle?.rect?.fill === 'outside'}
outsideDimension={14}
customTooltip={() => <AnnotationTooltip annotation={annotation} />}
/>
);
}

View file

@ -0,0 +1,17 @@
/*
* 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 { ObsRectAnnotation } from './new_rect_annotation';
import { ObsLineAnnotation } from './new_line_annotation';
import { Annotation } from '../../../../common/annotations';
export function ObsAnnotation({ annotation }: { annotation: Annotation }) {
if (!annotation.event?.end || annotation.annotation.type === 'line') {
return <ObsLineAnnotation annotation={annotation} />;
}
return <ObsRectAnnotation annotation={annotation} />;
}

View file

@ -0,0 +1,69 @@
/*
* 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 {
SeriesIdentifier,
Tooltip,
TooltipAction,
TooltipSpec,
TooltipType,
} from '@elastic/charts';
import { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import moment from 'moment';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { CreateAnnotationForm } from './create_annotation';
import { Annotation } from '../../../../common/annotations';
import { DisplayAnnotation } from '../display_annotations';
import { NewLineAnnotation } from './new_line_annotation';
import { NewRectAnnotation } from './new_rect_annotation';
export interface ObservabilityAnnotationsProps {
slo?: SLOWithSummaryResponse;
tooltipSpecs?: Partial<TooltipSpec>;
annotations?: Annotation[];
isCreateOpen: boolean;
setIsCreateOpen: (value: boolean) => void;
}
export function ObservabilityAnnotations({
slo,
tooltipSpecs,
annotations,
isCreateOpen,
setIsCreateOpen,
}: ObservabilityAnnotationsProps) {
const { setValue } = useFormContext<CreateAnnotationForm>();
const actions: Array<TooltipAction<any, SeriesIdentifier>> = [
{
label: i18n.translate(
'xpack.observability.createAnnotation.addAnnotationModalHeaderTitleLabel',
{ defaultMessage: 'Create annotation' }
),
onSelect: (s, v) => {
setValue('@timestamp', moment(new Date(s?.[0]?.datum.key ?? v?.[0]?.datum.key)));
setIsCreateOpen(true);
},
},
];
return (
<EuiErrorBoundary>
<Tooltip {...(tooltipSpecs ?? {})} actions={actions} type={TooltipType.VerticalCursor} />
<DisplayAnnotation annotations={annotations} />
<NewLineAnnotation slo={slo} isCreateOpen={isCreateOpen} />
<NewRectAnnotation slo={slo} isCreateOpen={isCreateOpen} />
</EuiErrorBoundary>
);
}
// eslint-disable-next-line import/no-default-export
export default ObservabilityAnnotations;

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 { i18n } from '@kbn/i18n';
import React from 'react';
import { FieldSelector } from './common/field_selector';
import { Annotation } from '../../../../common/annotations';
export function ServiceApplyTo({ editAnnotation }: { editAnnotation?: Annotation | null }) {
return (
<FieldSelector
label={i18n.translate('xpack.observability.annotationMeta.euiFormRow.serviceLabel', {
defaultMessage: 'Service',
})}
fieldName="service.name"
name="service.name"
placeholder="Select a service"
dataTestSubj="serviceSelector"
/>
);
}

View file

@ -0,0 +1,49 @@
/*
* 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 { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import { ALL_VALUE } from '@kbn/slo-schema';
import React from 'react';
import { Annotation } from '../../../../common/annotations';
import { SloSelector } from './slo_selector';
export function SLOApplyTo({ editAnnotation }: { editAnnotation?: Annotation | null }) {
const { control } = useFormContext<Annotation>();
return (
<EuiFormRow
label={i18n.translate('xpack.observability.annotationMeta.euiFormRow.sloLabel', {
defaultMessage: 'SLOs',
})}
display="columnCompressed"
fullWidth={true}
>
<Controller
defaultValue={editAnnotation?.slo}
name="slo"
control={control}
render={({ field }) => (
<SloSelector
value={field.value}
onSelected={(newValue) => {
const { slo, all } = newValue;
if (all) {
field.onChange({
id: ALL_VALUE,
});
} else {
field.onChange(slo);
}
}}
/>
)}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,131 @@
/*
* 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, { useEffect, useMemo, useState } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useFetchSloList } from '../hooks/use_fetch_slo_list';
export interface SloItem {
id: string;
instanceId?: string;
name?: string;
groupBy?: string;
}
interface Props {
value?: SloItem;
onSelected: (vals: { slo?: { id: string; instanceId?: string }; all?: boolean }) => void;
hasError?: boolean;
}
type Option = EuiComboBoxOptionOption<string>;
const mapSlosToOptions = (slos: SLOWithSummaryResponse[] | SloItem[] | undefined) =>
slos?.map((slo) => ({
label:
slo.instanceId !== ALL_VALUE
? `${slo.name ?? slo.id} (${slo.instanceId})`
: slo.name ?? slo.id,
value: `${slo.id}-${slo.instanceId}`,
})) ?? [];
export function SloSelector({ value, onSelected, hasError }: Props) {
const [options, setOptions] = useState<Option[]>([]);
const [selectedOptions, setSelectedOptions] = useState<Option[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const query = `${searchValue}*`;
const { isLoading, data: sloList } = useFetchSloList({
kqlQuery: `slo.name: (${query}) or slo.instanceId.text: (${query})`,
perPage: 100,
});
useEffect(() => {
const isLoadedWithData = !isLoading && sloList?.results !== undefined;
const opts: Option[] = isLoadedWithData ? mapSlosToOptions(sloList?.results) : [];
setOptions(opts);
}, [isLoading, sloList]);
useEffect(() => {
if (value && sloList?.results.length) {
const selectedSlos = sloList.results.filter(
(slo) => value.id === slo.id && value.instanceId === slo.instanceId
);
const newOpts = mapSlosToOptions(selectedSlos);
if (value?.id === ALL_VALUE) {
newOpts.unshift(ALL_OPTION);
}
setSelectedOptions(newOpts);
}
}, [value, sloList]);
const onChange = (opts: Option[]) => {
const isAllSelected = opts.find((opt) => opt.value === ALL_VALUE);
const prevIsAllSelected = selectedOptions.find((opt) => opt.value === ALL_VALUE);
if (isAllSelected && !prevIsAllSelected) {
setSelectedOptions([ALL_OPTION]);
onSelected({ all: true });
} else {
setSelectedOptions(opts);
const selectedSlos =
opts.length >= 1
? sloList!.results?.filter((slo) =>
opts.find((opt) => opt.value === `${slo.id}-${slo.instanceId}`)
)
: [];
onSelected({
slo: selectedSlos.map((slo) => ({ id: slo.id, instanceId: slo.instanceId }))[0],
});
}
};
const onSearchChange = useMemo(
() =>
debounce((val: string) => {
setSearchValue(val);
}, 300),
[]
);
return (
<EuiComboBox
async
compressed
aria-label={SLO_LABEL}
placeholder={SLO_SELECTOR}
data-test-subj="sloSelector"
options={[ALL_OPTION, ...options]}
selectedOptions={selectedOptions}
isLoading={isLoading}
onChange={onChange}
fullWidth
onSearchChange={onSearchChange}
isInvalid={hasError}
singleSelection={{ asPlainText: true }}
/>
);
}
const ALL_OPTION = {
label: i18n.translate('xpack.observability.sloEmbeddable.config.sloSelector.all', {
defaultMessage: 'All SLOs',
}),
value: ALL_VALUE,
};
export const SLO_SELECTOR = i18n.translate(
'xpack.observability.sloEmbeddable.config.sloSelector.placeholder',
{
defaultMessage: 'Select a SLO',
}
);
export const SLO_LABEL = i18n.translate(
'xpack.observability.sloEmbeddable.config.sloSelector.ariaLabel',
{
defaultMessage: 'SLO',
}
);

View file

@ -0,0 +1,63 @@
/*
* 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 { EuiButtonGroup, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import React from 'react';
export function TextDecoration() {
const { control } = useFormContext();
return (
<EuiFormRow
label={i18n.translate('xpack.observability.annotationForm.euiFormRow.textDecoration', {
defaultMessage: 'Text decoration',
})}
display="columnCompressed"
fullWidth
>
<Controller
name="annotation.style.line.textDecoration"
control={control}
render={({ field }) => (
<EuiButtonGroup
buttonSize="compressed"
isFullWidth={true}
id="positionOptions"
idSelected={field.value}
onChange={(id) => {
field.onChange(id);
}}
options={textOptions}
legend={i18n.translate(
'xpack.observability.annotationForm.positionLabel.textDecoration',
{
defaultMessage: 'Text decoration',
}
)}
/>
)}
/>
</EuiFormRow>
);
}
const textOptions = [
{
id: 'none',
label: i18n.translate('xpack.observability.annotationForm.decoration.none', {
defaultMessage: 'None',
}),
},
{
id: 'name',
label: i18n.translate('xpack.observability.annotationForm.decoration.name', {
defaultMessage: 'Name',
}),
},
];

View file

@ -0,0 +1,32 @@
/*
* 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 { EuiIcon, formatDate } from '@elastic/eui';
import React from 'react';
import { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
export function TimestampRangeLabel({
annotation,
}: {
annotation: Annotation | CreateAnnotationParams;
}) {
if (annotation.event?.end) {
return (
<div>
{formatDate(annotation['@timestamp'], 'longDateTime')}
<EuiIcon
type="sortRight"
css={{
margin: '0 5px',
}}
/>
{formatDate(annotation.event?.end, 'longDateTime')}
</div>
);
}
return <>{formatDate(annotation['@timestamp'], 'longDateTime')}</>;
}

View file

@ -0,0 +1,60 @@
/*
* 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 moment, { Moment } from 'moment';
import {
defaultAnnotationColor,
defaultAnnotationLabel,
defaultAnnotationRangeColor,
defaultRangeAnnotationLabel,
} from '@kbn/event-annotation-common';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import type { CreateAnnotationForm } from './components/create_annotation';
export function getDefaultAnnotation({
slo,
timestamp,
eventEnd,
}: {
timestamp?: Moment;
eventEnd?: Moment;
slo?: SLOWithSummaryResponse;
}): CreateAnnotationForm {
const sloId = slo?.id;
const sloInstanceId = slo?.instanceId;
return {
message: eventEnd ? defaultRangeAnnotationLabel : defaultAnnotationLabel,
'@timestamp': timestamp ?? moment(),
event: {
start: timestamp,
end: eventEnd,
},
annotation: {
title: eventEnd ? defaultRangeAnnotationLabel : defaultAnnotationLabel,
style: {
icon: 'triangle',
color: eventEnd ? defaultAnnotationRangeColor : defaultAnnotationColor,
line: {
width: 2,
style: 'solid',
textDecoration: 'name',
},
rect: {
fill: 'inside',
},
},
},
...(sloId
? {
slo: {
id: sloId,
instanceId: sloInstanceId,
},
}
: {}),
};
}

View file

@ -0,0 +1,20 @@
/*
* 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, { memo } from 'react';
import { Annotation } from '../../../common/annotations';
import { ObsAnnotation } from './components/obs_annotation';
export const DisplayAnnotation = memo(({ annotations }: { annotations?: Annotation[] }) => {
return (
<>
{annotations?.map((annotation, index) => (
<ObsAnnotation annotation={annotation} key={annotation.id ?? index} />
))}
</>
);
});

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 { useUpdateAnnotation } from './use_update_annotation';
import { useCreateAnnotation } from './use_create_annotation';
import { useDeleteAnnotation } from './use_delete_annotation';
export const useAnnotationCRUDS = () => {
const { mutateAsync: createAnnotation, isLoading: isSaving } = useCreateAnnotation();
const { mutateAsync: updateAnnotation, isLoading: isUpdating } = useUpdateAnnotation();
const { mutateAsync: deleteAnnotation, isLoading: isDeleting } = useDeleteAnnotation();
return {
updateAnnotation,
createAnnotation,
deleteAnnotation,
isSaving,
isUpdating,
isDeleting,
isLoading: isSaving || isUpdating || isDeleting,
};
};

View file

@ -0,0 +1,37 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../../utils/kibana_react';
export interface AnnotationsPermissions {
read: boolean;
write: boolean;
index: string;
hasGoldLicense: boolean;
}
export function useAnnotationPermissions() {
const { http } = useKibana().services;
const { isLoading, isError, isSuccess, data, refetch } = useQuery({
queryKey: ['fetchAnnotationPermissions'],
queryFn: async ({}) => {
return await http.get<AnnotationsPermissions>('/api/observability/annotation/permissions');
},
refetchOnWindowFocus: false,
keepPreviousData: true,
});
return {
data,
isLoading,
isSuccess,
isError,
refetch,
};
}

View file

@ -0,0 +1,80 @@
/*
* 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type { FindSLOResponse } from '@kbn/slo-schema';
import { QueryKey, useMutation } from '@tanstack/react-query';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { Annotation, CreateAnnotationParams } from '../../../../common/annotations';
import { useKibana } from '../../../utils/kibana_react';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export interface CreateAnnotationResponse {
_id: string;
_index: string;
_source: Annotation;
}
export function useCreateAnnotation() {
const {
i18n: i18nStart,
theme,
http,
notifications: { toasts },
} = useKibana().services;
const services = useKibana().services;
return useMutation<
CreateAnnotationResponse,
ServerError,
{ annotation: CreateAnnotationParams },
{ previousData?: FindSLOResponse; queryKey?: QueryKey }
>(
['createAnnotation'],
({ annotation }) => {
if (!annotation.message) {
annotation.message = annotation.annotation?.title ?? '';
}
const body = JSON.stringify(annotation);
return http.post<CreateAnnotationResponse>(`/api/observability/annotation`, { body });
},
{
onSuccess: (data, { annotation }) => {
toasts.addSuccess({
title: toMountPoint(
<RedirectAppLinks coreStart={services} data-test-subj="observabilityMainContainer">
<FormattedMessage
id="xpack.observability.annotation.create.successNotification"
defaultMessage="Successfully created annotation {name}"
values={{
name: annotation.message,
}}
/>
</RedirectAppLinks>,
{
i18n: i18nStart,
theme,
}
),
});
},
onError: (error, { annotation }, context) => {
toasts.addError(new Error(error.body?.message ?? error.message), {
title: i18n.translate('xpack.observability.create.annotation', {
defaultMessage: 'Something went wrong while creating annotation {message}',
values: { message: annotation.annotation?.title ?? annotation.message },
}),
});
},
}
);
}

View file

@ -0,0 +1,71 @@
/*
* 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { QueryKey, useMutation } from '@tanstack/react-query';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { Annotation } from '../../../../common/annotations';
import { useKibana } from '../../../utils/kibana_react';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export function useDeleteAnnotation() {
const {
i18n: i18nStart,
theme,
http,
notifications: { toasts },
} = useKibana().services;
const services = useKibana().services;
return useMutation<void, ServerError, { annotations: Annotation[] }, { queryKey?: QueryKey }>(
['deleteAnnotation'],
async ({ annotations }) => {
for (const annotation of annotations) {
await http.delete(`/api/observability/annotation/${annotation.id}`);
}
return Promise.resolve();
},
{
onSuccess: (data, { annotations }) => {
toasts.addSuccess({
title: toMountPoint(
<RedirectAppLinks coreStart={services} data-test-subj="observabilityMainContainer">
<FormattedMessage
id="xpack.observability.annotation.delete.successNotification"
defaultMessage="Successfully deleted annotations {name}."
values={{
name: annotations.map((annotation) => annotation.message).join(', '),
}}
/>
</RedirectAppLinks>,
{
i18n: i18nStart,
theme,
}
),
});
},
onError: (error, { annotations }, context) => {
toasts.addError(new Error(error.body?.message ?? error.message), {
title: i18n.translate('xpack.observability.delete.annotation', {
defaultMessage: 'Something went wrong while deleting annotation {message}',
values: {
message: annotations
.map((annotation) => annotation.annotation?.title ?? annotation.message)
.join(', '),
},
}),
});
},
}
);
}

View file

@ -0,0 +1,38 @@
/*
* 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 moment from 'moment';
import { useEffect } from 'react';
import { UseFormReset } from 'react-hook-form';
import type { Annotation } from '../../../../common/annotations';
import type { CreateAnnotationForm } from '../components/create_annotation';
export const useEditAnnotationHelper = ({
reset,
editAnnotation,
setIsCreateOpen,
}: {
reset: UseFormReset<CreateAnnotationForm>;
editAnnotation?: Annotation | null;
setIsCreateOpen: (val: boolean) => void;
}) => {
useEffect(() => {
if (!editAnnotation) return;
const eventEnd = editAnnotation.event?.end;
reset({
...editAnnotation,
'@timestamp': moment(editAnnotation['@timestamp']),
event: {
start: moment(editAnnotation.event?.start),
end: eventEnd ? moment(eventEnd) : undefined,
},
});
setIsCreateOpen(true);
}, [editAnnotation, setIsCreateOpen, reset]);
};

View file

@ -0,0 +1,60 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import type { Annotation } from '../../../../common/annotations';
import { useKibana } from '../../../utils/kibana_react';
export interface FindAnnotationsResponse {
items: Annotation[];
total: number;
}
export function useFetchAnnotations({
start,
end,
slo,
}: {
start: string;
end: string;
slo?: SLOWithSummaryResponse;
}) {
const { http } = useKibana().services;
const sloId = slo?.id;
const sloInstanceId = slo?.instanceId;
let serviceName: string | undefined;
if (slo?.indicator.params && 'service' in slo?.indicator.params) {
serviceName = slo?.indicator.params.service;
}
const { isLoading, isError, isSuccess, data, refetch } = useQuery({
queryKey: ['fetchAnnotationList', start, end, sloId, sloInstanceId, serviceName],
queryFn: async ({}) => {
return await http.get<FindAnnotationsResponse>('/api/observability/annotation/find', {
query: {
start,
end,
serviceName,
sloId,
sloInstanceId,
},
});
},
refetchOnWindowFocus: false,
keepPreviousData: true,
});
return {
data,
isLoading,
isSuccess,
isError,
refetch,
};
}

View file

@ -0,0 +1,70 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import moment from 'moment';
import { useKibana } from '../../../utils/kibana_react';
export type Suggestion = string;
export interface UseFetchApmSuggestions {
suggestions: Suggestion[];
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
}
export interface Params {
fieldName: string;
search?: string;
serviceName?: string;
}
interface ApiResponse {
terms: string[];
}
const NO_SUGGESTIONS: Suggestion[] = [];
export function useFetchApmSuggestions({
fieldName,
search = '',
serviceName = '',
}: Params): UseFetchApmSuggestions {
const { http } = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: ['fetchApmSuggestions', fieldName, search, serviceName],
queryFn: async ({ signal }) => {
try {
const { terms = [] } = await http.get<ApiResponse>('/internal/apm/suggestions', {
query: {
fieldName,
start: moment().subtract(2, 'days').toISOString(),
end: moment().toISOString(),
fieldValue: search,
...(!!serviceName && fieldName !== 'service.name' && { serviceName }),
},
signal,
});
return terms;
} catch (error) {
// ignore error
}
},
refetchOnWindowFocus: false,
keepPreviousData: true,
});
return {
suggestions: isInitialLoading ? NO_SUGGESTIONS : data ?? NO_SUGGESTIONS,
isLoading: isInitialLoading || isLoading || isRefetching,
isSuccess,
isError,
};
}

View file

@ -0,0 +1,80 @@
/*
* 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 { FindSLOResponse } from '@kbn/slo-schema';
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../../utils/kibana_react';
export interface SLOListParams {
kqlQuery?: string;
page?: number;
sortBy?: string;
sortDirection?: 'asc' | 'desc';
perPage?: number;
lastRefresh?: number;
disabled?: boolean;
}
export interface UseFetchSloListResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: FindSLOResponse | undefined;
}
export function useFetchSloList({
kqlQuery = '',
page = 1,
sortBy = 'status',
sortDirection = 'desc',
perPage = 25,
lastRefresh,
disabled = false,
}: SLOListParams = {}): UseFetchSloListResponse {
const {
http,
notifications: { toasts },
} = useKibana().services;
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: ['slo', { kqlQuery, page, sortBy, sortDirection, perPage, lastRefresh }],
queryFn: async ({ signal }) => {
return await http.get<FindSLOResponse>(`/api/observability/slos`, {
query: {
...(sortBy && { sortBy }),
...(sortDirection && { sortDirection }),
...(page !== undefined && { page }),
...(perPage !== undefined && { perPage }),
hideStale: true,
},
signal,
});
},
enabled: !disabled,
cacheTime: 0,
refetchOnWindowFocus: false,
onError: (error: Error) => {
toasts.addError(error, {
title: i18n.translate('xpack.observability.list.errorNotification', {
defaultMessage: 'Something went wrong while fetching SLOs',
}),
});
},
});
return {
data,
isInitialLoading,
isLoading,
isRefetching,
isSuccess,
isError,
};
}

View file

@ -0,0 +1,85 @@
/*
* 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type { FindSLOResponse } from '@kbn/slo-schema';
import { QueryKey, useMutation } from '@tanstack/react-query';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { Annotation } from '../../../../common/annotations';
import { useKibana } from '../../../utils/kibana_react';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export interface CreateAnnotationResponse {
_id: string;
_index: string;
_source: Annotation;
}
export function useUpdateAnnotation() {
const {
i18n: i18nStart,
theme,
http,
notifications: { toasts },
} = useKibana().services;
const services = useKibana().services;
return useMutation<
CreateAnnotationResponse,
ServerError,
{ annotation: Annotation },
{ previousData?: FindSLOResponse; queryKey?: QueryKey }
>(
['updateAnnotation'],
async ({ annotation }) => {
if (!annotation.message) {
annotation.message = annotation.annotation?.title ?? '';
}
const body = JSON.stringify(annotation);
return await http.put<CreateAnnotationResponse>(
`/api/observability/annotation/${annotation.id}`,
{
body,
}
);
},
{
onSuccess: (data, { annotation }) => {
toasts.addSuccess({
title: toMountPoint(
<RedirectAppLinks coreStart={services} data-test-subj="observabilityMainContainer">
<FormattedMessage
id="xpack.observability.annotation.updated.successNotification"
defaultMessage="Successfully updated annotation {name}"
values={{
name: annotation.annotation?.title ?? annotation.message,
}}
/>
</RedirectAppLinks>,
{
i18n: i18nStart,
theme,
}
),
});
},
onError: (error, { annotation }, context) => {
toasts.addError(new Error(error.body?.message ?? error.message), {
title: i18n.translate('xpack.observability.update.annotation', {
defaultMessage: 'Something went wrong while updating annotation {message}',
values: { message: annotation.annotation?.title ?? annotation.message },
}),
});
},
}
);
}

View file

@ -0,0 +1,129 @@
/*
* 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 { AvailableMetricIcon } from '@kbn/expression-metric-vis-plugin/common';
import { type IconSet } from '@kbn/visualization-ui-components';
export const iconsSet: IconSet<AvailableMetricIcon> = [
{
value: 'empty',
label: i18n.translate('xpack.observability.metric.iconSelect.noIconLabel', {
defaultMessage: 'None',
}),
},
{
value: 'sortUp',
label: i18n.translate('xpack.observability.metric.iconSelect.sortUpLabel', {
defaultMessage: 'Sort up',
}),
},
{
value: 'sortDown',
label: i18n.translate('xpack.observability.metric.iconSelect.sortDownLabel', {
defaultMessage: 'Sort down',
}),
},
{
value: 'compute',
label: i18n.translate('xpack.observability.metric.iconSelect.computeLabel', {
defaultMessage: 'Compute',
}),
},
{
value: 'globe',
label: i18n.translate('xpack.observability.metric.iconSelect.globeLabel', {
defaultMessage: 'Globe',
}),
},
{
value: 'temperature',
label: i18n.translate('xpack.observability.metric.iconSelect.temperatureLabel', {
defaultMessage: 'Temperature',
}),
},
{
value: 'asterisk',
label: i18n.translate('xpack.observability.metric.iconSelect.asteriskIconLabel', {
defaultMessage: 'Asterisk',
}),
},
{
value: 'alert',
label: i18n.translate('xpack.observability.metric.iconSelect.alertIconLabel', {
defaultMessage: 'Alert',
}),
},
{
value: 'bell',
label: i18n.translate('xpack.observability.metric.iconSelect.bellIconLabel', {
defaultMessage: 'Bell',
}),
},
{
value: 'bolt',
label: i18n.translate('xpack.observability.metric.iconSelect.boltIconLabel', {
defaultMessage: 'Bolt',
}),
},
{
value: 'bug',
label: i18n.translate('xpack.observability.metric.iconSelect.bugIconLabel', {
defaultMessage: 'Bug',
}),
},
{
value: 'editorComment',
label: i18n.translate('xpack.observability.metric.iconSelect.commentIconLabel', {
defaultMessage: 'Comment',
}),
},
{
// @ts-ignore
value: 'iInCircle',
label: i18n.translate('xpack.observability.metric.iconSelect.infoLabel', {
defaultMessage: 'Info',
}),
},
{
value: 'flag',
label: i18n.translate('xpack.observability.metric.iconSelect.flagIconLabel', {
defaultMessage: 'Flag',
}),
},
{
value: 'heart',
label: i18n.translate('xpack.observability.metric.iconSelect.heartLabel', {
defaultMessage: 'Heart',
}),
},
{
value: 'mapMarker',
label: i18n.translate('xpack.observability.metric.iconSelect.mapMarkerLabel', {
defaultMessage: 'Map Marker',
}),
},
{
value: 'pin',
label: i18n.translate('xpack.observability.metric.iconSelect.mapPinLabel', {
defaultMessage: 'Map Pin',
}),
},
{
value: 'starEmpty',
label: i18n.translate('xpack.observability.metric.iconSelect.starLabel', {
defaultMessage: 'Star',
}),
},
{
value: 'tag',
label: i18n.translate('xpack.observability.metric.iconSelect.tagIconLabel', {
defaultMessage: 'Tag',
}),
},
];

View file

@ -0,0 +1,214 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import { BrushEvent, TooltipSpec, LineAnnotationEvent, RectAnnotationEvent } from '@elastic/charts';
import { FormProvider, useForm } from 'react-hook-form';
import moment from 'moment';
import useKey from 'react-use/lib/useKey';
import { clone } from 'lodash';
import {
defaultRangeAnnotationLabel,
defaultAnnotationRangeColor,
} from '@kbn/event-annotation-common';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { getDefaultAnnotation } from './default_annotation';
import { useEditAnnotationHelper } from './hooks/use_edit_annotation_helper';
import type { CreateAnnotationForm } from './components/create_annotation';
import { ObservabilityAnnotations, CreateAnnotation } from './components';
import { useFetchAnnotations } from './hooks/use_fetch_annotations';
import type { Annotation } from '../../../common/annotations';
import { useAnnotationCRUDS } from './hooks/use_annotation_cruds';
export const useAnnotations = ({
domain,
editAnnotation,
slo,
setEditAnnotation,
}: {
slo?: SLOWithSummaryResponse;
editAnnotation?: Annotation | null;
setEditAnnotation?: (annotation: Annotation | null) => void;
domain?: {
min: number | string;
max: number | string;
};
} = {}) => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const methods = useForm<CreateAnnotationForm>({
defaultValues: getDefaultAnnotation({ slo }),
mode: 'all',
});
const { setValue, reset } = methods;
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
const [selectedEditAnnotation, setSelectedEditAnnotation] = useState<Annotation | null>(null);
const { data, refetch } = useFetchAnnotations({
start: domain?.min ? String(domain?.min) : 'now-30d',
end: domain?.min ? String(domain?.max) : 'now',
slo,
});
useKey(
(event) => event.metaKey,
(event) => {
setIsCtrlPressed(event.type === 'keydown');
}
);
useEditAnnotationHelper({
reset,
editAnnotation,
setIsCreateOpen,
});
const onCancel = useCallback(() => {
setValue('event.end', null);
setIsCreateOpen(false);
setSelectedEditAnnotation(null);
setEditAnnotation?.(null);
}, [setEditAnnotation, setValue]);
const { createAnnotation, updateAnnotation, deleteAnnotation, isLoading } = useAnnotationCRUDS();
const AddAnnotationButton = useMemo(() => {
if (!isCreateOpen) return () => null;
return () => (
<CreateAnnotation
onSave={() => {
setIsCreateOpen(false);
refetch();
}}
onCancel={onCancel}
editAnnotation={editAnnotation ?? selectedEditAnnotation}
isCreateAnnotationsOpen={isCreateOpen}
createAnnotation={createAnnotation}
updateAnnotation={updateAnnotation}
deleteAnnotation={deleteAnnotation}
isLoading={isLoading}
/>
);
}, [
createAnnotation,
deleteAnnotation,
editAnnotation,
isCreateOpen,
isLoading,
onCancel,
refetch,
selectedEditAnnotation,
updateAnnotation,
]);
return {
annotations: data?.items ?? [],
onAnnotationClick: (annotations: {
rects: RectAnnotationEvent[];
lines: LineAnnotationEvent[];
}) => {
if (annotations.rects.length) {
const selectedAnnotation = data?.items?.find((a) => annotations.rects[0].id.includes(a.id));
if (selectedAnnotation) {
const editData = clone(selectedAnnotation);
reset({
...editData,
'@timestamp': moment(editData['@timestamp']),
event: {
end: editData.event ? moment(editData.event.end) : undefined,
start: moment(editData['@timestamp']),
},
});
setIsCreateOpen(true);
setSelectedEditAnnotation(editData);
}
}
if (annotations.lines.length) {
const selectedAnnotation = data?.items?.find((a) => annotations.lines[0].id.includes(a.id));
if (selectedAnnotation) {
const editData = clone(selectedAnnotation);
reset({
...editData,
'@timestamp': moment(editData['@timestamp']),
});
setSelectedEditAnnotation(editData);
setIsCreateOpen(true);
}
}
},
wrapOnBrushEnd: (originalHandler: (event: BrushEvent) => void) => {
return (event: BrushEvent) => {
if (isCtrlPressed) {
setSelectedEditAnnotation(null);
const { to, from } = getBrushData(event);
reset(
getDefaultAnnotation({
slo,
timestamp: moment(from),
eventEnd: moment(to),
})
);
setIsCreateOpen(true);
} else {
// Call the original handler
originalHandler?.(event);
}
};
},
createAnnotation: (start: string | number, end?: string | null) => {
if (isCreateOpen) return;
reset(getDefaultAnnotation({ slo }));
if (isNaN(Number(start))) {
setValue('@timestamp', moment(start));
} else {
setValue('@timestamp', moment(new Date(Number(start))));
}
if (end) {
setValue('event.end', moment(new Date(Number(end))));
}
if (end) {
setValue('message', defaultRangeAnnotationLabel);
setValue('annotation.style.color', defaultAnnotationRangeColor);
}
setIsCreateOpen(true);
},
AddAnnotationButton: () => {
return (
<FormProvider {...methods}>
<AddAnnotationButton />
</FormProvider>
);
},
ObservabilityAnnotations: ({
tooltipSpecs,
annotations,
}: {
tooltipSpecs?: Partial<TooltipSpec>;
annotations?: Annotation[];
}) => {
return (
<FormProvider {...methods}>
<ObservabilityAnnotations
tooltipSpecs={tooltipSpecs}
annotations={annotations}
slo={slo}
isCreateOpen={isCreateOpen}
setIsCreateOpen={setIsCreateOpen}
/>
<AddAnnotationButton />
</FormProvider>
);
},
};
};
function getBrushData(e: BrushEvent) {
const [from, to] = [Number(e.x?.[0]), Number(e.x?.[1])];
const [fromUtc, toUtc] = [moment(from).format(), moment(to).format()];
return { from: fromUtc, to: toUtc };
}

View file

@ -98,6 +98,7 @@ export {
AutocompleteField,
WithKueryAutocompletion,
} from './components/rule_kql_filter';
export { useAnnotations } from './components/annotations/use_annotations';
export { RuleConditionChart } from './components/rule_condition_chart';
export { getGroupFilters } from '../common/custom_threshold_rule/helpers/get_group';
export type { GenericAggType } from './components/rule_condition_chart/rule_condition_chart';

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiText } from '@elastic/eui';
import React from 'react';
import { ALL_VALUE } from '@kbn/slo-schema';
import { Annotation } from '../../../common/annotations';
export function AnnotationApplyTo({ annotation }: { annotation: Annotation }) {
const slo = annotation.slo;
const serviceName = annotation.service?.name;
let sloLabel = slo
? i18n.translate('xpack.observability.columns.sloTextLabel', {
defaultMessage: 'SLO: {slo}',
values: { slo: slo.id },
})
: '';
const isAllSlos = slo?.id === ALL_VALUE;
if (isAllSlos) {
sloLabel = i18n.translate('xpack.observability.columns.sloTextLabel.all', {
defaultMessage: 'SLOs: All',
});
}
const serviceLabel = serviceName
? i18n.translate('xpack.observability.columns.serviceLabel', {
defaultMessage: 'Service: {serviceName}',
values: { serviceName },
})
: '';
if (!slo && !serviceName) {
return (
<EuiText size="s">
{i18n.translate('xpack.observability.columns.TextLabel', { defaultMessage: '--' })}
</EuiText>
);
}
return (
<EuiText size="s">
{serviceLabel}
{sloLabel}
</EuiText>
);
}

View file

@ -0,0 +1,54 @@
/*
* 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 { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { i18n } from '@kbn/i18n';
import { useAnnotationsPrivileges } from './annotations_privileges';
import { CreateAnnotationBtn } from './create_annotation_btn';
import { AnnotationsList } from './annotations_list';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { paths } from '../../../common/locators/paths';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';
export const ANNOTATIONS_PAGE_ID = 'annotations-container';
export function AnnotationsPage() {
const {
http: { basePath },
} = useKibana().services;
const { ObservabilityPageTemplate } = usePluginContext();
const checkPrivileges = useAnnotationsPrivileges();
useBreadcrumbs([
{
href: basePath.prepend(paths.observability.annotations),
text: i18n.translate('xpack.observability.breadcrumbs.annotationsLinkText', {
defaultMessage: 'Annotations',
}),
deepLinkId: 'observability-overview',
},
]);
return (
<ObservabilityPageTemplate
data-test-subj="annotationsPage"
pageHeader={{
pageTitle: i18n.translate('xpack.observability.annotations.heading', {
defaultMessage: 'Annotations',
}),
rightSideItems: [<CreateAnnotationBtn />],
}}
>
<HeaderMenu />
{checkPrivileges ? checkPrivileges : <AnnotationsList />}
</ObservabilityPageTemplate>
);
}

View file

@ -0,0 +1,220 @@
/*
* 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, { useState } from 'react';
import {
EuiInMemoryTable,
EuiBasicTableColumn,
EuiTableSelectionType,
EuiSearchBarProps,
EuiSpacer,
} from '@elastic/eui';
import { TagsList } from '@kbn/observability-shared-plugin/public';
import { DeleteAnnotationsModal } from '../../components/annotations/components/common/delete_annotations_modal';
import { useDeleteAnnotation } from '../../components/annotations/hooks/use_delete_annotation';
import { DeleteAnnotations } from '../../components/annotations/components/common/delete_annotations';
import { useAnnotationPermissions } from '../../components/annotations/hooks/use_annotation_permissions';
import { AnnotationApplyTo } from './annotation_apply_to';
import { TimestampRangeLabel } from '../../components/annotations/components/timestamp_range_label';
import { DatePicker } from './date_picker';
import { AnnotationsListChart } from './annotations_list_chart';
import { Annotation } from '../../../common/annotations';
import { useFetchAnnotations } from '../../components/annotations/hooks/use_fetch_annotations';
export function AnnotationsList() {
const { data: permissions } = useAnnotationPermissions();
const [selection, setSelection] = useState<Annotation[]>([]);
const [isEditing, setIsEditing] = useState<Annotation | null>(null);
const [start, setStart] = useState('now-30d');
const [end, setEnd] = useState('now');
const { data, isLoading, refetch } = useFetchAnnotations({
start,
end,
});
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const { mutateAsync: deleteAnnotation, isLoading: isDeleteLoading } = useDeleteAnnotation();
const onDelete = async (deleteSelection?: Annotation) => {
if (deleteSelection) {
await deleteAnnotation({ annotations: [deleteSelection] });
} else {
await deleteAnnotation({ annotations: selection });
setSelection([]);
}
refetch();
};
const renderToolsLeft = () => {
return (
<DeleteAnnotations
selection={selection}
isLoading={isDeleteLoading}
permissions={permissions}
setIsDeleteModalVisible={setIsDeleteModalVisible}
/>
);
};
const renderToolsRight = () => {
return [
<DatePicker start={start} end={end} setStart={setStart} setEnd={setEnd} refetch={refetch} />,
];
};
const allTags = data?.items?.map((obj) => obj.tags ?? []).flat() ?? [];
const search: EuiSearchBarProps = {
toolsLeft: renderToolsLeft(),
toolsRight: renderToolsRight(),
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection',
field: 'tags',
name: TAGS_LABEL,
multiSelect: true,
options: [...new Set(allTags)].map((tag) => ({ value: tag, name: tag })),
},
],
};
const pagination = {
initialPageSize: 5,
pageSizeOptions: [5, 10, 25, 50],
};
const selectionValue: EuiTableSelectionType<Annotation> = {
selectable: (annot) => true,
onSelectionChange: (annotSel) => setSelection(annotSel),
initialSelected: selection,
};
const columns: Array<EuiBasicTableColumn<Annotation>> = [
{
field: 'annotation.title',
name: TITLE_LABEL,
sortable: true,
},
{
field: '@timestamp',
name: TIMESTAMP_LABEL,
dataType: 'date',
sortable: true,
render: (timestamp: string, annotation: Annotation) => {
return <TimestampRangeLabel annotation={annotation} />;
},
},
{
field: 'message',
name: MESSAGE_LABEL,
sortable: true,
truncateText: true,
},
{
field: 'tags',
name: TAGS_LABEL,
render: (tags: string[]) => <TagsList tags={tags} />,
},
{
name: APPLY_TO_LABEL,
render: (annotation: Annotation) => {
return <AnnotationApplyTo annotation={annotation} />;
},
},
{
actions: [
{
name: EDIT_LABEL,
description: EDIT_LABEL,
type: 'icon',
icon: 'pencil',
onClick: (annotation) => {
setIsEditing(annotation);
},
enabled: () => permissions?.write ?? false,
},
{
name: DELETE_LABEL,
description: DELETE_LABEL,
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (annotation) => {
setSelection([annotation]);
setIsDeleteModalVisible(true);
},
enabled: () => permissions?.write ?? false,
},
],
},
];
return (
<>
<EuiSpacer size="m" />
<EuiInMemoryTable
childrenBetween={
<AnnotationsListChart
data={data?.items ?? []}
start={start}
end={end}
isEditing={isEditing}
setIsEditing={setIsEditing}
permissions={permissions}
/>
}
tableCaption={i18n.translate('xpack.observability.annotationsTableCaption', {
defaultMessage: 'List of annotations for the selected time range.',
})}
items={data?.items ?? []}
itemId="id"
loading={isLoading}
columns={columns}
search={search}
pagination={pagination}
sorting={true}
selection={selectionValue}
tableLayout="auto"
/>
<DeleteAnnotationsModal
selection={selection}
isDeleteModalVisible={isDeleteModalVisible}
setSelection={setSelection}
onDelete={onDelete}
setIsDeleteModalVisible={setIsDeleteModalVisible}
/>
</>
);
}
const TITLE_LABEL = i18n.translate('xpack.observability.titleLabel', {
defaultMessage: 'Title',
});
const APPLY_TO_LABEL = i18n.translate('xpack.observability.applyTo', {
defaultMessage: 'Apply to',
});
const EDIT_LABEL = i18n.translate('xpack.observability.editAnnotation', { defaultMessage: 'Edit' });
const DELETE_LABEL = i18n.translate('xpack.observability.deleteAnnotation', {
defaultMessage: 'Delete',
});
const TAGS_LABEL = i18n.translate('xpack.observability.tagsAnnotations', {
defaultMessage: 'Tags',
});
const MESSAGE_LABEL = i18n.translate('xpack.observability.messageAnnotations', {
defaultMessage: 'Message',
});
const TIMESTAMP_LABEL = i18n.translate('xpack.observability.timestampAnnotations', {
defaultMessage: 'Timestamp',
});

View file

@ -0,0 +1,192 @@
/*
* 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, { useMemo } from 'react';
import {
Axis,
BrushEndListener,
Chart,
Position,
ScaleType,
Settings,
TooltipHeader,
TooltipContainer,
TooltipTable,
XYChartElementEvent,
BarSeries,
} from '@elastic/charts';
import { EuiButton, EuiHorizontalRule, EuiToolTip, formatDate } from '@elastic/eui';
import { InPortal } from 'react-reverse-portal';
import { i18n } from '@kbn/i18n';
import { parse } from '@kbn/datemath';
import { TooltipValue } from '@elastic/charts/dist/specs';
import moment from 'moment';
import { AnnotationsPermissions } from '../../components/annotations/hooks/use_annotation_permissions';
import { createAnnotationPortal } from './create_annotation_btn';
import { useAnnotations } from '../../components/annotations/use_annotations';
import { Annotation } from '../../../common/annotations';
export function AnnotationsListChart({
data,
start,
end,
isEditing,
setIsEditing,
permissions,
}: {
data: Annotation[];
start: string;
end: string;
isEditing: Annotation | null;
permissions?: AnnotationsPermissions;
setIsEditing: (annotation: Annotation | null) => void;
}) {
const { ObservabilityAnnotations, createAnnotation, onAnnotationClick } = useAnnotations({
editAnnotation: isEditing,
setEditAnnotation: setIsEditing,
});
const brushEndListener: BrushEndListener = ({ x }) => {
if (!x) {
return;
}
const startX = x[0];
const endX = x[1];
createAnnotation(String(startX), String(endX));
};
const domain = useMemo(() => {
return {
min: parse(start)?.valueOf()!,
max: parse(end, { roundUp: true })?.valueOf()!,
};
}, [end, start]);
// we need at least two points for chart to render
const domainPoints =
// we need at least two points for chart to render
[
{ x: domain.min, y: 1 },
{ x: domain.min + 1, y: 1 },
{ x: domain.min + 1, y: 1 },
{ x: domain.max, y: 1 },
];
return (
<>
<InPortal node={createAnnotationPortal}>
<EuiToolTip
content={
!permissions?.write
? i18n.translate('xpack.observability.createAnnotation.missingPermissions', {
defaultMessage: 'You do not have permission to create annotations',
})
: ''
}
>
<EuiButton
isDisabled={!permissions?.write}
data-test-subj="o11yRenderToolsRightCreateAnnotationButton"
key="createAnnotation"
onClick={() => {
createAnnotation(moment().subtract(1, 'day').toISOString());
}}
fill={true}
>
{i18n.translate('xpack.observability.renderToolsRight.createAnnotationButtonLabel', {
defaultMessage: 'Create annotation',
})}
</EuiButton>
</EuiToolTip>
</InPortal>
<Chart size={{ height: 300 }}>
<Settings
onElementClick={([geometry, _]) => {
const point = geometry as XYChartElementEvent;
if (!point) return;
createAnnotation(point[0]?.x);
}}
onBrushEnd={brushEndListener}
onAnnotationClick={onAnnotationClick}
xDomain={domain}
theme={{
chartMargins: {
top: 50,
},
barSeriesStyle: {
rect: {
opacity: 0,
},
},
}}
/>
<ObservabilityAnnotations
annotations={data}
tooltipSpecs={{
customTooltip: (props) => <Tooltip {...props} />,
}}
/>
<Axis
id="horizontal"
position={Position.Bottom}
title={i18n.translate('xpack.observability.annotationsListChart.axis.timestampLabel', {
defaultMessage: '@timestamp',
})}
tickFormat={(d) => formatDate(d, 'longDateTime')}
groupId="primary"
/>
<Axis
id="vertical"
title={i18n.translate('xpack.observability.annotationsListChart.axis.yDomainLabel', {
defaultMessage: 'Y Domain',
})}
position={Position.Left}
hide={true}
/>
<BarSeries
id="bars"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
data={domainPoints}
/>
</Chart>
<EuiHorizontalRule margin="s" />
</>
);
}
function Tooltip({
header,
values,
}: {
header: {
formattedValue?: string;
} | null;
values: TooltipValue[];
}) {
return (
<TooltipContainer>
<TooltipHeader>{header?.formattedValue}</TooltipHeader>
<TooltipTable
columns={[
{ type: 'color' },
{
type: 'text',
cell: (t) =>
i18n.translate('xpack.observability.tooltip.p.clickToAddAnnotationLabel', {
defaultMessage: 'Click to add annotation',
}),
style: { textAlign: 'left' },
},
]}
items={values}
/>
</TooltipContainer>
);
}

View file

@ -0,0 +1,142 @@
/*
* 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 {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiButton,
EuiMarkdownFormat,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../utils/kibana_react';
import { useAnnotationPermissions } from '../../components/annotations/hooks/use_annotation_permissions';
export function useAnnotationsPrivileges() {
const { data: permissions, isLoading } = useAnnotationPermissions();
if (permissions && !isLoading && !permissions?.read) {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="center"
style={{ height: 'calc(100vh - 150px)' }}
>
<EuiFlexItem grow={false}>
<Unprivileged unprivilegedIndices={[permissions.index]} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!isLoading && !permissions?.hasGoldLicense) {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="center"
style={{ height: 'calc(100vh - 150px)' }}
>
<EuiFlexItem grow={false}>
<LicenseExpired />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return null;
}
function Unprivileged({ unprivilegedIndices }: { unprivilegedIndices: string[] }) {
return (
<EuiEmptyPrompt
data-test-subj="syntheticsUnprivileged"
color="plain"
icon={<EuiIcon type="logoObservability" size="xl" />}
title={
<h2>
<FormattedMessage
id="xpack.observability.noFindingsStates.unprivileged.unprivilegedTitle"
defaultMessage="Privileges required"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.observability.noFindingsStates.unprivileged.unprivilegedDescription"
defaultMessage="To view Observability annotations data, you must update privileges. For more information, contact your Kibana administrator."
/>
</p>
}
footer={
<EuiMarkdownFormat
css={css`
text-align: initial;
`}
children={
i18n.translate(
'xpack.observability.noFindingsStates.unprivileged.unprivilegedFooterMarkdown',
{
defaultMessage:
'Required Elasticsearch index privilege `read` for the following indices:',
}
) + unprivilegedIndices.map((idx) => `\n- \`${idx}\``)
}
/>
}
/>
);
}
function LicenseExpired() {
const licenseManagementEnabled = useKibana().services.licenseManagement?.enabled;
const {
http: { basePath },
} = useKibana().services;
return (
<EuiEmptyPrompt
data-test-subj="syntheticsUnprivileged"
iconType="warning"
iconColor="warning"
title={
<h2>
<FormattedMessage
id="xpack.observability.license.invalidLicenseTitle"
defaultMessage="Invalid License"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.observability.license.invalidLicenseDescription"
defaultMessage="You need a Gold license to view Observability annotations data. For more information, contact your Kibana administrator."
/>
</p>
}
actions={[
<EuiButton
aria-label={i18n.translate('xpack.observability.invalidLicense.manageYourLicenseButton', {
defaultMessage: 'Navigate to license management',
})}
data-test-subj="apmInvalidLicenseNotificationManageYourLicenseButton"
isDisabled={!licenseManagementEnabled}
href={basePath + '/app/management/stack/license_management'}
>
{i18n.translate('xpack.observability.invalidLicense.licenseManagementLink', {
defaultMessage: 'Manage your license',
})}
</EuiButton>,
]}
/>
);
}

View file

@ -0,0 +1,15 @@
/*
* 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 { createHtmlPortalNode, OutPortal } from 'react-reverse-portal';
export const createAnnotationPortal = createHtmlPortalNode();
export function CreateAnnotationBtn() {
return <OutPortal node={createAnnotationPortal} />;
}

View file

@ -0,0 +1,57 @@
/*
* 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 { EuiSuperDatePicker } from '@elastic/eui';
import React from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
export function DatePicker({
start,
end,
setStart,
setEnd,
refetch,
}: {
start: string;
end: string;
setStart: (val: string) => void;
setEnd: (val: string) => void;
refetch: () => void;
}) {
const { uiSettings } = useKibana().services;
return (
<EuiSuperDatePicker
css={{
maxWidth: 500,
}}
onTimeChange={(val) => {
setStart(val.start);
setEnd(val.end);
}}
start={start}
end={end}
onRefresh={(val) => {
setStart(val.start);
setEnd(val.end);
refetch();
}}
commonlyUsedRanges={uiSettings
?.get('timepicker:quickRanges')
.map(({ from, to, display }: { from: string; to: string; display: string }) => {
return {
start: from,
end: to,
label: display,
};
})}
updateButtonProps={{
fill: false,
}}
/>
);
}

View file

@ -18,7 +18,7 @@ import { useKibana } from '../../../../utils/kibana_react';
import HeaderMenuPortal from './header_menu_portal';
export function HeaderMenu(): React.ReactElement | null {
const { share, theme } = useKibana().services;
const { share, theme, http } = useKibana().services;
const onboardingLocator = share?.url.locators.get<ObservabilityOnboardingLocatorParams>(
OBSERVABILITY_ONBOARDING_LOCATOR
@ -40,6 +40,15 @@ export function HeaderMenu(): React.ReactElement | null {
defaultMessage: 'Add data',
})}
</EuiHeaderLink>
<EuiHeaderLink
color="primary"
href={http.basePath.prepend('/app/observability/annotations')}
iconType="brush"
>
{i18n.translate('xpack.observability.home.annotations', {
defaultMessage: 'Annotations',
})}
</EuiHeaderLink>
</EuiHeaderLinks>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -69,6 +69,7 @@ import type { UiActionsStart, UiActionsSetup } from '@kbn/ui-actions-plugin/publ
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public';
import { observabilityAppId, observabilityFeatureId } from '../common';
import {
ALERTS_PATH,
@ -139,6 +140,7 @@ export interface ObservabilityPublicPluginsStart {
guidedOnboarding?: GuidedOnboardingPluginStart;
lens: LensPublicStart;
licensing: LicensingPluginStart;
licenseManagement?: LicenseManagementUIPluginSetup;
navigation: NavigationPublicPluginStart;
observabilityShared: ObservabilitySharedPluginStart;
observabilityAIAssistant?: ObservabilityAIAssistantPublicStart;

View file

@ -7,6 +7,7 @@
import React from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { AnnotationsPage } from '../pages/annotations/annotations';
import { DatePickerContextProvider } from '../context/date_picker_context/date_picker_context';
import { useKibana } from '../utils/kibana_react';
import { AlertsPage } from '../pages/alerts/alerts';
@ -27,6 +28,7 @@ import {
RULES_LOGS_PATH,
RULES_PATH,
RULE_DETAIL_PATH,
ANNOTATIONS_PATH,
OLD_SLOS_PATH,
OLD_SLOS_WELCOME_PATH,
OLD_SLOS_OUTDATED_DEFINITIONS_PATH,
@ -173,4 +175,11 @@ export const routes = {
params: {},
exact: true,
},
[ANNOTATIONS_PATH]: {
handler: () => {
return <AnnotationsPage />;
},
params: {},
exact: true,
},
};

View file

@ -10,6 +10,7 @@
import { offeringBasedSchema, schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import { DEFAULT_ANNOTATION_INDEX } from '../common/annotations';
import type { ObservabilityPluginSetup } from './plugin';
import { createOrUpdateIndex, Mappings } from './utils/create_or_update_index';
import { createOrUpdateIndexTemplate } from './utils/create_or_update_index_template';
@ -36,7 +37,7 @@ export * from './types';
const configSchema = schema.object({
annotations: schema.object({
enabled: schema.boolean({ defaultValue: true }),
index: schema.string({ defaultValue: 'observability-annotations' }),
index: schema.string({ defaultValue: DEFAULT_ANNOTATION_INDEX }),
}),
unsafe: schema.object({
alertDetails: schema.object({

View file

@ -6,23 +6,23 @@
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import * as t from 'io-ts';
import Boom from '@hapi/boom';
import { ILicense } from '@kbn/licensing-plugin/server';
import { QueryDslQueryContainer, SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import { formatAnnotation } from './format_annotations';
import { checkAnnotationsPermissions } from './permissions';
import { ANNOTATION_MAPPINGS } from './mappings/annotation_mappings';
import {
createAnnotationRt,
deleteAnnotationRt,
Annotation,
getAnnotationByIdRt,
CreateAnnotationParams,
DEFAULT_ANNOTATION_INDEX,
DeleteAnnotationParams,
FindAnnotationParams,
GetByIdAnnotationParams,
} from '../../../common/annotations';
import { createOrUpdateIndex } from '../../utils/create_or_update_index';
import { mappings } from './mappings';
import { unwrapEsResponse } from '../../../common/utils/unwrap_es_response';
type CreateParams = t.TypeOf<typeof createAnnotationRt>;
type DeleteParams = t.TypeOf<typeof deleteAnnotationRt>;
type GetByIdParams = t.TypeOf<typeof getAnnotationByIdRt>;
export function createAnnotationsClient(params: {
index: string;
esClient: ElasticsearchClient;
@ -31,12 +31,15 @@ export function createAnnotationsClient(params: {
}) {
const { index, esClient, logger, license } = params;
const readIndex =
index === DEFAULT_ANNOTATION_INDEX ? index : `${index},${DEFAULT_ANNOTATION_INDEX}`;
const initIndex = () =>
createOrUpdateIndex({
index,
mappings,
client: esClient,
logger,
mappings: ANNOTATION_MAPPINGS,
});
function ensureGoldLicense<T extends (...args: any[]) => any>(fn: T): T {
@ -48,14 +51,35 @@ export function createAnnotationsClient(params: {
}) as T;
}
const updateMappings = async () => {
// get index mapping
const currentMappings = await esClient.indices.getMapping({
index,
});
const mappings = currentMappings?.[index].mappings;
if (mappings?.properties?.slo) {
return;
}
// update index mapping
await initIndex();
};
const validateAnnotation = (annotation: CreateAnnotationParams | Annotation) => {
// make sure to check one of message of annotation.title is present
if (!annotation.message && !annotation.annotation.title) {
throw Boom.badRequest('Annotation must have a message or a annotation.title');
}
};
return {
get index() {
return index;
},
index,
create: ensureGoldLicense(
async (
createParams: CreateParams
createParams: CreateAnnotationParams
): Promise<{ _id: string; _index: string; _source: Annotation }> => {
validateAnnotation(createParams);
const indexExists = await unwrapEsResponse(
esClient.indices.exists(
{
@ -67,14 +91,21 @@ export function createAnnotationsClient(params: {
if (!indexExists) {
await initIndex();
} else {
await updateMappings();
}
const annotation = {
...createParams,
event: {
...createParams.event,
created: new Date().toISOString(),
},
};
if (!annotation.annotation.title) {
// TODO: handle this when we integrate with the APM UI
annotation.annotation.title = annotation.message;
}
const body = await unwrapEsResponse(
esClient.index(
@ -87,6 +118,54 @@ export function createAnnotationsClient(params: {
)
);
const document = (
await esClient.get<Annotation>(
{
index,
id: body._id,
},
{ meta: true }
)
).body as { _id: string; _index: string; _source: Annotation };
return {
_id: document._id,
_index: document._index,
_source: formatAnnotation(document._source),
};
}
),
update: ensureGoldLicense(
async (
updateParams: Annotation
): Promise<{ _id: string; _index: string; _source: Annotation }> => {
validateAnnotation(updateParams);
await updateMappings();
const { id, ...rest } = updateParams;
const annotation = {
...rest,
event: {
...rest.event,
updated: new Date().toISOString(),
},
};
if (!annotation.annotation.title) {
// TODO: handle this when we integrate with the APM UI
annotation.annotation.title = annotation.message;
}
const body = await unwrapEsResponse(
esClient.index(
{
index,
id,
body: annotation,
refresh: 'wait_for',
},
{ meta: true }
)
);
return (
await esClient.get<Annotation>(
{
@ -98,32 +177,140 @@ export function createAnnotationsClient(params: {
).body as { _id: string; _index: string; _source: Annotation };
}
),
getById: ensureGoldLicense(async (getByIdParams: GetByIdParams) => {
getById: ensureGoldLicense(async (getByIdParams: GetByIdAnnotationParams) => {
const { id } = getByIdParams;
return unwrapEsResponse(
esClient.get(
{
id,
index,
const response = await esClient.search<Annotation>({
index: readIndex,
ignore_unavailable: true,
query: {
bool: {
filter: [
{
term: {
_id: id,
},
},
],
},
{ meta: true }
)
);
},
});
const document = response.hits.hits?.[0];
return {
...document,
_source: formatAnnotation(document._source!),
};
}),
delete: ensureGoldLicense(async (deleteParams: DeleteParams) => {
find: ensureGoldLicense(async (findParams: FindAnnotationParams) => {
const { start, end, sloId, sloInstanceId, serviceName } = findParams ?? {};
const shouldClauses: QueryDslQueryContainer[] = [];
if (sloId || sloInstanceId) {
const query: QueryDslQueryContainer = {
bool: {
should: [
{
term: {
'slo.id': '*',
},
},
{
bool: {
filter: [
...(sloId
? [
{
match_phrase: {
'slo.id': sloId,
},
},
]
: []),
...(sloInstanceId
? [
{
match_phrase: {
'slo.instanceId': sloInstanceId,
},
},
]
: []),
],
},
},
],
},
};
shouldClauses.push(query);
}
const result = await esClient.search({
index: readIndex,
size: 10000,
ignore_unavailable: true,
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: start ?? 'now-30d',
lte: end ?? 'now',
},
},
},
{
bool: {
should: [
...(serviceName
? [
{
term: {
'service.name': serviceName,
},
},
]
: []),
...shouldClauses,
],
},
},
],
},
},
});
const items = result.hits.hits.map((hit) => ({
...formatAnnotation(hit._source as Annotation),
id: hit._id,
}));
return {
items,
total: (result.hits.total as SearchTotalHits)?.value,
};
}),
delete: ensureGoldLicense(async (deleteParams: DeleteAnnotationParams) => {
const { id } = deleteParams;
return unwrapEsResponse(
esClient.delete(
{
index,
id,
refresh: 'wait_for',
return await esClient.deleteByQuery({
index: readIndex,
ignore_unavailable: true,
body: {
query: {
term: {
_id: id,
},
},
{ meta: true }
)
);
},
refresh: true,
});
}),
permissions: async () => {
const permissions = await checkAnnotationsPermissions({ index, esClient });
const hasGoldLicense = license?.hasAtLeast('gold') ?? false;
return { index, hasGoldLicense, ...permissions.index[index] };
},
};
}

View file

@ -0,0 +1,19 @@
/*
* 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 { Annotation } from '../../../common/annotations';
export const formatAnnotation = (annotation: Annotation) => {
// copy message to title if title is not set
return {
...annotation,
annotation: {
...annotation.annotation,
title: annotation.annotation.title || annotation.message,
},
};
};

View file

@ -1,48 +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.
*/
export const mappings = {
dynamic: 'strict',
properties: {
annotation: {
properties: {
type: {
type: 'keyword',
},
},
},
message: {
type: 'text',
},
tags: {
type: 'keyword',
},
'@timestamp': {
type: 'date',
},
event: {
properties: {
created: {
type: 'date',
},
},
},
service: {
properties: {
name: {
type: 'keyword',
},
environment: {
type: 'keyword',
},
version: {
type: 'keyword',
},
},
},
},
} as const;

View file

@ -0,0 +1,100 @@
/*
* 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 { Mappings } from '../../../utils/create_or_update_index';
export const ANNOTATION_MAPPINGS: Mappings = {
dynamic: 'strict',
properties: {
annotation: {
properties: {
title: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
},
},
},
type: {
type: 'keyword',
},
style: {
type: 'flattened',
},
},
},
message: {
type: 'text',
},
tags: {
type: 'keyword',
},
'@timestamp': {
type: 'date',
},
event: {
properties: {
start: {
type: 'date',
},
end: {
type: 'date',
},
created: {
type: 'date',
},
updated: {
type: 'date',
},
},
},
service: {
properties: {
name: {
type: 'keyword',
},
environment: {
type: 'keyword',
},
version: {
type: 'keyword',
},
},
},
host: {
properties: {
name: {
type: 'keyword',
},
},
},
slo: {
properties: {
id: {
type: 'keyword',
},
instanceId: {
type: 'keyword',
},
},
},
monitor: {
properties: {
id: {
type: 'keyword',
},
},
},
alert: {
properties: {
id: {
type: 'keyword',
},
},
},
},
};

View file

@ -0,0 +1,28 @@
/*
* 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 { SecurityIndexPrivilege } from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
export const checkAnnotationsPermissions = async ({
index,
esClient,
}: {
index: string;
esClient: ElasticsearchClient;
}) => {
return esClient.security.hasPrivileges({
body: {
index: [
{
names: [index],
privileges: ['read', 'write'] as SecurityIndexPrivilege[],
},
],
},
});
};

View file

@ -8,12 +8,14 @@
import * as t from 'io-ts';
import { schema } from '@kbn/config-schema';
import { CoreSetup, RequestHandler, Logger } from '@kbn/core/server';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { isLeft } from 'fp-ts/lib/Either';
import { formatErrors } from '@kbn/securitysolution-io-ts-utils';
import {
getAnnotationByIdRt,
createAnnotationRt,
deleteAnnotationRt,
findAnnotationRt,
updateAnnotationRt,
} from '../../../common/annotations';
import { ScopedAnnotationsClient } from './bootstrap_annotations';
import { createAnnotationsClient } from './create_annotations_client';
@ -53,7 +55,7 @@ export function registerAnnotationAPIs({
if (isLeft(validation)) {
return response.badRequest({
body: PathReporter.report(validation).join(', '),
body: formatErrors(validation.left).join('|'),
});
}
@ -77,10 +79,11 @@ export function registerAnnotationAPIs({
body: res,
});
} catch (err) {
logger.error(err);
return response.custom({
statusCode: err.output?.statusCode ?? 500,
body: {
message: err.output?.payload?.message ?? 'An internal server error occured',
message: err.output?.payload?.message ?? 'An internal server error occurred',
},
});
}
@ -101,6 +104,18 @@ export function registerAnnotationAPIs({
})
);
router.put(
{
path: '/api/observability/annotation/{id}',
validate: {
body: unknowns,
},
},
wrapRouteHandler(t.type({ body: updateAnnotationRt }), ({ data, client }) => {
return client.update(data.body);
})
);
router.delete(
{
path: '/api/observability/annotation/{id}',
@ -124,4 +139,28 @@ export function registerAnnotationAPIs({
return client.getById(data.params);
})
);
router.get(
{
path: '/api/observability/annotation/find',
validate: {
query: unknowns,
},
},
wrapRouteHandler(t.type({ query: findAnnotationRt }), ({ data, client }) => {
return client.find(data.query);
})
);
router.get(
{
path: '/api/observability/annotation/permissions',
validate: {
query: unknowns,
},
},
wrapRouteHandler(t.type({}), ({ client }) => {
return client.permissions();
})
);
}

View file

@ -18,7 +18,7 @@ export async function createOrUpdateIndex({
logger,
}: {
index: string;
mappings: Mappings;
mappings?: Mappings;
client: ElasticsearchClient;
logger: Logger;
}) {

View file

@ -102,6 +102,12 @@
"@kbn/react-kibana-mount",
"@kbn/core-chrome-browser",
"@kbn/navigation-plugin",
"@kbn/visualization-ui-components",
"@kbn/expression-metric-vis-plugin",
"@kbn/securitysolution-io-ts-utils",
"@kbn/event-annotation-components",
"@kbn/slo-schema",
"@kbn/license-management-plugin",
],
"exclude": [
"target/**/*"

View file

@ -0,0 +1,100 @@
/*
* 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 { journey, step, before, expect } from '@elastic/synthetics';
import { AnnotationDataService } from '../services/annotation_data_service';
import { SLoDataService } from '../services/slo_data_service';
import { sloAppPageProvider } from '../page_objects/slo_app';
journey(`AnnotationsList`, async ({ page, params }) => {
const sloApp = sloAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
const dataService = new SLoDataService({
kibanaUrl: params.kibanaUrl,
elasticsearchUrl: params.elasticsearchUrl,
getService: params.getService,
});
const annotationService = new AnnotationDataService(params);
before(async () => {
await dataService.generateSloData();
await dataService.addSLO();
await annotationService.deleteAnnotationsIndex();
});
step('Go to slos overview', async () => {
await page.goto(`${params.kibanaUrl}/app/observability/annotations`, {
waitUntil: 'networkidle',
});
await sloApp.loginToKibana();
});
step('create an annotation', async () => {
await page.click('text="Create annotation"');
await page.getByTestId('annotationTitle').fill('Test annotation');
await page.getByTestId('annotationTitle').blur();
await page.getByTestId('annotationMessage').fill('Test annotation description');
await page.getByTestId('annotationMessage').blur();
await page.getByTestId('annotationTags').click();
await page.getByTestId('sloSelector').getByTestId('comboBoxSearchInput').click();
await page.click('text="All SLOs"');
await page.click('text=Save');
await page.getByTestId('toastCloseButton').click();
});
step('validate annotation list', async () => {
await page.waitForSelector('text="Test annotation"');
await expect(await page.locator('.euiTableRow')).toHaveCount(1);
await page.locator('.echAnnotation__marker').first().hover();
await page.waitForSelector('text="Test annotation description"');
});
step('Go to slos', async () => {
await page.getByTestId('observability-nav-slo-slos').click();
await page.click('text="Test Stack SLO"');
// scroll to the bottom of the page
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
});
step('check that annotation is displayed', async () => {
await page.locator('.echAnnotation__marker').nth(1).hover();
await page.waitForSelector('text="Test annotation description"');
});
step('update annotation', async () => {
await page.locator('.echAnnotation__marker').nth(1).click();
await page.getByTestId('annotationTitle').fill('Updated annotation');
await page.getByTestId('annotationTitle').blur();
await page.getByTestId('annotationMessage').fill('Updated annotation description');
await page.getByTestId('annotationMessage').blur();
await page.getByTestId('annotationSaveButton').click();
await page.getByTestId('toastCloseButton').click();
await page.waitForSelector('text="Updated annotation"');
await page.locator('.echAnnotation__marker').nth(1).hover();
await page.waitForSelector('text="Updated annotation description"');
});
step('delete annotation', async () => {
await page.locator('.echAnnotation__marker').nth(1).click();
await page.getByTestId('annotationDeleteButton').first().click();
await page.getByTestId('toastCloseButton').click();
await expect(await page.locator('.echAnnotation__marker')).toHaveCount(1);
});
});

View file

@ -6,3 +6,4 @@
*/
export * from './slos_overview.journey';
export * from './annotation_list.journey';

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 { FtrProviderContext } from '@kbn/ftr-common-functional-services';
import { KbnClient } from '@kbn/test';
export class AnnotationDataService {
kibanaUrl: string;
elasticsearchUrl: string;
params: Record<string, any>;
requester: KbnClient['requester'];
getService: FtrProviderContext['getService'];
constructor(params: Record<string, any>) {
this.kibanaUrl = params.kibanaUrl;
this.elasticsearchUrl = params.elasticsearchUrl;
this.requester = params.getService('kibanaServer').requester;
this.params = params;
this.getService = params.getService;
}
async deleteAnnotationsIndex() {
const esClient = this.getService('es');
try {
await esClient.indices.delete({
index: 'observability-annotations',
ignore_unavailable: true,
});
await esClient.indices.delete({
index: 'observability-annotations',
ignore_unavailable: true,
});
} catch (e) {
// ignore
}
}
}

View file

@ -45,6 +45,15 @@ export function HeaderMenu(): React.ReactElement | null {
})}
</EuiHeaderLink>
)}
<EuiHeaderLink
color="primary"
href={http.basePath.prepend('/app/observability/annotations')}
iconType="brush"
>
{i18n.translate('xpack.slo.home.annotations', {
defaultMessage: 'Annotations',
})}
</EuiHeaderLink>
</EuiHeaderLinks>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -99,6 +99,7 @@ export function ErrorBudgetChart({ data, isLoading, slo, selectedTabId, onBrushe
data={data}
isLoading={isLoading}
onBrushed={onBrushed}
slo={slo}
/>
</EuiFlexItem>
</>

View file

@ -0,0 +1,119 @@
/*
* 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 { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import numeral from '@elastic/numeral';
import React, { useRef } from 'react';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { GetPreviewDataResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import moment from 'moment';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { TimeBounds } from '../types';
import { useKibana } from '../../../utils/kibana_react';
export function EventsAreaChart({
slo,
data,
minValue,
maxValue,
annotation,
onBrushed,
}: {
data?: GetPreviewDataResponse;
maxValue?: number | null;
minValue?: number | null;
slo: SLOWithSummaryResponse;
annotation?: React.ReactNode;
onBrushed?: (timeBounds: TimeBounds) => void;
}) {
const { charts, uiSettings } = useKibana().services;
const baseTheme = charts.theme.useChartsBaseTheme();
const dateFormat = uiSettings.get('dateFormat');
const chartRef = useRef(null);
const yAxisNumberFormat = slo.indicator.type === 'sli.metric.timeslice' ? '0,0[.00]' : '0,0';
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
const domain = {
fit: true,
min:
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
max:
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
};
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd } = useAnnotations({
domain,
slo,
});
return (
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
<Settings
baseTheme={baseTheme}
showLegend={slo.indicator.type !== 'sli.metric.timeslice'}
legendPosition={Position.Left}
noResults={
<EuiIcon
type="visualizeApp"
size="l"
color="subdued"
title={i18n.translate('xpack.slo.eventsChartPanel.euiIcon.noResultsLabel', {
defaultMessage: 'no results',
})}
/>
}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
pointerUpdateDebounce={0}
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onBrushEnd={wrapOnBrushEnd((brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
})}
/>
{annotation}
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={(d) => moment(d).format(dateFormat)}
/>
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
domain={domain}
/>
<AreaSeries
id="Metric"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={(data ?? []).map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue,
}))}
/>
</Chart>
);
}

View file

@ -4,19 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
AnnotationDomainType,
AreaSeries,
Axis,
Chart,
LineAnnotation,
Position,
RectAnnotation,
ScaleType,
Settings,
Tooltip,
TooltipType,
} from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
@ -26,22 +14,18 @@ import {
EuiPanel,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { max, min } from 'lodash';
import moment from 'moment';
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { TimesliceAnnotation } from './timeslice_annotation';
import { EventsAreaChart } from './events_area_chart';
import { TimeBounds } from '../types';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { SloTabId } from './slo_details';
import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
import { useKibana } from '../../../utils/kibana_react';
import { COMPARATOR_MAPPING } from '../../slo_edit/constants';
import { GoodBadEventsChart } from '../../slos/components/common/good_bad_events_chart';
import { getDiscoverLink } from '../../../utils/slo/get_discover_link';
@ -53,13 +37,7 @@ export interface Props {
}
export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props) {
const { charts, uiSettings, discover } = useKibana().services;
const { euiTheme } = useEuiTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const { discover } = useKibana().services;
const { isLoading, data } = useGetPreviewData({
range,
@ -70,8 +48,6 @@ export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props
remoteName: slo.remote?.remoteName,
});
const dateFormat = uiSettings.get('dateFormat');
const title =
slo.indicator.type !== 'sli.metric.timeslice' ? (
<EuiTitle size="xs">
@ -90,11 +66,6 @@ export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props
</h2>
</EuiTitle>
);
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
const yAxisNumberFormat = slo.indicator.type === 'sli.metric.timeslice' ? '0,0[.00]' : '0,0';
const values = (data || []).map((row) => {
if (slo.indicator.type === 'sli.metric.timeslice') {
@ -105,48 +76,8 @@ export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props
});
const maxValue = max(values);
const minValue = min(values);
const domain = {
fit: true,
min:
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
max:
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
};
const annotation =
slo.indicator.type === 'sli.metric.timeslice' && threshold ? (
<>
<LineAnnotation
id="thresholdAnnotation"
domainType={AnnotationDomainType.YDomain}
dataValues={[{ dataValue: threshold }]}
style={{
line: {
strokeWidth: 2,
stroke: euiTheme.colors.warning || '#000',
opacity: 1,
},
}}
marker={<span>{threshold}</span>}
markerPosition="right"
/>
<RectAnnotation
dataValues={[
{
coordinates: ['GT', 'GTE'].includes(slo.indicator.params.metric.comparator)
? {
y0: threshold,
y1: maxValue,
}
: { y0: minValue, y1: threshold },
details: `${COMPARATOR_MAPPING[slo.indicator.params.metric.comparator]} ${threshold}`,
},
]}
id="thresholdShade"
style={{ fill: euiTheme.colors.warning || '#000', opacity: 0.1 }}
/>
</>
) : null;
const annotation = <TimesliceAnnotation slo={slo} minValue={minValue} maxValue={maxValue} />;
const showViewEventsLink = ![
'sli.apm.transactionErrorRate',
@ -206,59 +137,13 @@ export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props
)}
{!isLoading && (
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
<Tooltip type={TooltipType.VerticalCursor} />
<Settings
baseTheme={baseTheme}
showLegend={slo.indicator.type !== 'sli.metric.timeslice'}
legendPosition={Position.Left}
noResults={
<EuiIcon
type="visualizeApp"
size="l"
color="subdued"
title={i18n.translate('xpack.slo.eventsChartPanel.euiIcon.noResultsLabel', {
defaultMessage: 'no results',
})}
/>
}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
pointerUpdateDebounce={0}
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onBrushEnd={(brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
}}
/>
{annotation}
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={(d) => moment(d).format(dateFormat)}
/>
<Axis
id="left"
position={Position.Left}
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
domain={domain}
/>
<AreaSeries
id="Metric"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="date"
yAccessors={['value']}
data={(data ?? []).map((datum) => ({
date: new Date(datum.date).getTime(),
value: datum.sliValue,
}))}
/>
</Chart>
<EventsAreaChart
slo={slo}
annotation={annotation}
minValue={minValue}
maxValue={maxValue}
onBrushed={onBrushed}
/>
)}
</>
)}

View file

@ -87,6 +87,7 @@ export function SliChartPanel({ data, isLoading, slo, selectedTabId, onBrushed }
<EuiFlexItem>
<WideChart
slo={slo}
chart="line"
id={i18n.translate('xpack.slo.sloDetails.sliHistoryChartPanel.chartTitle', {
defaultMessage: 'SLI value',

View file

@ -0,0 +1,62 @@
/*
* 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 { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { AnnotationDomainType, LineAnnotation, RectAnnotation } from '@elastic/charts';
import React from 'react';
import { useEuiTheme } from '@elastic/eui';
import { COMPARATOR_MAPPING } from '../../slo_edit/constants';
interface Props {
slo: SLOWithSummaryResponse;
maxValue?: number | null;
minValue?: number | null;
annotation?: React.ReactNode;
}
export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
const { euiTheme } = useEuiTheme();
const threshold =
slo.indicator.type === 'sli.metric.timeslice'
? slo.indicator.params.metric.threshold
: undefined;
return slo.indicator.type === 'sli.metric.timeslice' && threshold ? (
<>
<LineAnnotation
id="thresholdAnnotation"
domainType={AnnotationDomainType.YDomain}
dataValues={[{ dataValue: threshold }]}
style={{
line: {
strokeWidth: 2,
stroke: euiTheme.colors.warning || '#000',
opacity: 1,
},
}}
marker={<span>{threshold}</span>}
markerPosition="right"
/>
<RectAnnotation
dataValues={[
{
coordinates: ['GT', 'GTE'].includes(slo.indicator.params.metric.comparator)
? {
y0: threshold,
y1: maxValue,
}
: { y0: minValue, y1: threshold },
details: `${COMPARATOR_MAPPING[slo.indicator.params.metric.comparator]} ${threshold}`,
},
]}
id="thresholdShade"
style={{ fill: euiTheme.colors.warning || '#000', opacity: 0.1 }}
/>
</>
) : null;
}

View file

@ -14,8 +14,6 @@ import {
Position,
ScaleType,
Settings,
Tooltip,
TooltipType,
} from '@elastic/charts';
import { EuiIcon, EuiLoadingChart, useEuiTheme } from '@elastic/eui';
import numeral from '@elastic/numeral';
@ -24,6 +22,8 @@ import moment from 'moment';
import React, { useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { TimeBounds } from '../types';
import { useKibana } from '../../../utils/kibana_react';
@ -38,10 +38,11 @@ export interface Props {
chart: ChartType;
state: State;
isLoading: boolean;
slo: SLOWithSummaryResponse;
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function WideChart({ chart, data, id, isLoading, state, onBrushed }: Props) {
export function WideChart({ chart, data, id, isLoading, state, onBrushed, slo }: Props) {
const { charts, uiSettings } = useKibana().services;
const baseTheme = charts.theme.useChartsBaseTheme();
const { euiTheme } = useEuiTheme();
@ -51,6 +52,15 @@ export function WideChart({ chart, data, id, isLoading, state, onBrushed }: Prop
const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success;
const ChartComponent = chart === 'area' ? AreaSeries : LineSeries;
const { ObservabilityAnnotations, annotations, onAnnotationClick, wrapOnBrushEnd } =
useAnnotations({
slo,
domain: {
min: 'now-30d',
max: 'now',
},
});
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
@ -61,9 +71,12 @@ export function WideChart({ chart, data, id, isLoading, state, onBrushed }: Prop
}
return (
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
<Tooltip type={TooltipType.VerticalCursor} />
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
<Settings
theme={{
chartMargins: { top: 30 },
}}
baseTheme={baseTheme}
showLegend={false}
noResults={
@ -83,9 +96,10 @@ export function WideChart({ chart, data, id, isLoading, state, onBrushed }: Prop
pointerUpdateDebounce={0}
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onBrushEnd={(brushArea) => {
onBrushEnd={wrapOnBrushEnd((brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
}}
})}
onAnnotationClick={onAnnotationClick}
/>
<Axis
id="bottom"

View file

@ -12,8 +12,6 @@ import {
Position,
ScaleType,
Settings,
Tooltip,
TooltipType,
XYChartElementEvent,
} from '@elastic/charts';
import { EuiIcon, EuiLoadingChart, useEuiTheme } from '@elastic/eui';
@ -23,6 +21,7 @@ import { i18n } from '@kbn/i18n';
import { GetPreviewDataResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useRef } from 'react';
import { useAnnotations } from '@kbn/observability-plugin/public';
import { TimeBounds } from '../../../slo_details/types';
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
import { useKibana } from '../../../../utils/kibana_react';
@ -53,6 +52,11 @@ export function GoodBadEventsChart({
isDateHistogram: true,
});
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd, onAnnotationClick } =
useAnnotations({
slo,
});
const dateFormat = uiSettings.get('dateFormat');
const yAxisNumberFormat = '0,0';
@ -78,11 +82,11 @@ export function GoodBadEventsChart({
const barClickHandler = (params: XYChartElementEvent[]) => {
if (slo?.indicator?.type === 'sli.kql.custom') {
const [datanum, eventDetail] = params[0];
const [datum, eventDetail] = params[0];
const isBad = eventDetail.specId === badEventId;
const timeRange = {
from: moment(datanum.x).toISOString(),
to: moment(datanum.x).add(intervalInMilliseconds, 'ms').toISOString(),
from: moment(datum.x).toISOString(),
to: moment(datum.x).add(intervalInMilliseconds, 'ms').toISOString(),
mode: 'absolute' as const,
};
openInDiscover(discover, slo, isBad, !isBad, timeRange);
@ -94,9 +98,12 @@ export function GoodBadEventsChart({
{isLoading && <EuiLoadingChart size="m" mono data-test-subj="sliEventsChartLoading" />}
{!isLoading && (
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
<Tooltip type={TooltipType.VerticalCursor} />
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
<ObservabilityAnnotations annotations={annotations} />
<Settings
theme={{
chartMargins: { top: 30 },
}}
baseTheme={baseTheme}
showLegend={true}
legendPosition={Position.Left}
@ -118,9 +125,10 @@ export function GoodBadEventsChart({
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onElementClick={barClickHandler as ElementClickListener}
onBrushEnd={(brushArea) => {
onBrushEnd={wrapOnBrushEnd((brushArea) => {
onBrushed?.(getBrushTimeBounds(brushArea));
}}
})}
onAnnotationClick={onAnnotationClick}
/>
{annotation}
<Axis

View file

@ -33,7 +33,7 @@ export default function annotationApiTests({ getService }: FtrProviderContext) {
}
}
describe('Observability annotations', () => {
describe('ObservabilityAnnotations', () => {
describe('when creating an annotation', () => {
afterEach(async () => {
const indexExists = await es.indices.exists({ index: DEFAULT_INDEX_NAME });
@ -105,12 +105,9 @@ export default function annotationApiTests({ getService }: FtrProviderContext) {
expect(response.body).to.eql({
_index,
_id,
_primary_term: 1,
_seq_no: 0,
_version: 1,
found: true,
_source: {
annotation: {
title: 'test message',
type: 'deployment',
},
'@timestamp': timestamp,

View file

@ -0,0 +1,133 @@
/*
* 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 expect from '@kbn/expect';
import { JsonObject } from '@kbn/utility-types';
import { CreateAnnotationParams } from '@kbn/observability-plugin/common/annotations';
import moment from 'moment';
import { FtrProviderContext } from '../../common/ftr_provider_context';
const DEFAULT_INDEX_NAME = 'observability-annotations';
// eslint-disable-next-line import/no-default-export
export default function annotationApiTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('es');
function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) {
switch (method.toLowerCase()) {
case 'get':
return supertest.get(url).set('kbn-xsrf', 'foo');
case 'post':
return supertest.post(url).send(data).set('kbn-xsrf', 'foo');
case 'delete':
return supertest.delete(url).send(data).set('kbn-xsrf', 'foo');
default:
throw new Error(`Unsupported method ${method}`);
}
}
const createAnnotation = async (annotation: Partial<CreateAnnotationParams>) => {
const data: CreateAnnotationParams = {
annotation: {
type: 'slo',
},
'@timestamp': moment().subtract(1, 'day').toISOString(),
message: 'test message',
tags: ['apm'],
...annotation,
};
const response = await request({
url: '/api/observability/annotation',
method: 'POST',
data,
});
expect(response.status).to.eql(200);
};
const findAnnotations = async (params?: { sloId?: string; sloInstanceId?: string }) => {
const queryParams = new URLSearchParams();
if (params?.sloId) {
queryParams.set('sloId', params.sloId);
}
if (params?.sloInstanceId) {
queryParams.set('sloInstanceId', params.sloInstanceId);
}
const response = await request({
url: '/api/observability/annotation/find' + (queryParams.toString() ? `?${queryParams}` : ''),
method: 'GET',
});
expect(response.status).to.eql(200);
return response.body;
};
describe('ObservabilityFindAnnotations', () => {
after(async () => {
const indexExists = await es.indices.exists({ index: DEFAULT_INDEX_NAME });
if (indexExists) {
await es.indices.delete({
index: DEFAULT_INDEX_NAME,
});
}
});
before(async () => {
const indexExists = await es.indices.exists({ index: DEFAULT_INDEX_NAME });
if (indexExists) {
await es.indices.delete({
index: DEFAULT_INDEX_NAME,
});
}
});
it('creates few SLO annotations', async () => {
await createAnnotation({
slo: {
id: 'slo-id',
instanceId: 'instance-id',
},
});
await createAnnotation({
slo: {
id: 'slo-id2',
instanceId: 'instance-id',
},
});
});
it('can find annotation with slo id', async () => {
let response = await findAnnotations();
expect(response.items.length).to.eql(2);
const annotation = response.items[0];
expect(annotation.slo.id).to.eql('slo-id2');
response = await findAnnotations({ sloId: 'slo-id' });
expect(response.items.length).to.eql(1);
expect(response.items[0].slo.id).to.eql('slo-id');
});
it('can find annotation with slo instance Id', async () => {
const response = await findAnnotations({ sloInstanceId: 'instance-id' });
expect(response.items.length).to.eql(2);
expect(response.items[0].slo.instanceId).to.eql('instance-id');
});
it('can find annotation with slo instance Id and slo id', async () => {
const response = await findAnnotations({ sloInstanceId: 'instance-id', sloId: 'slo-id' });
expect(response.items.length).to.eql(1);
expect(response.items[0].slo.instanceId).to.eql('instance-id');
expect(response.items[0].slo.id).to.eql('slo-id');
});
});
}

View file

@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
describe('Observability specs (trial)', function () {
loadTestFile(require.resolve('./annotations'));
loadTestFile(require.resolve('./find_annotations'));
loadTestFile(require.resolve('./obs_alert_details_context'));
});
}