mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
4f3e94059f
commit
642216820e
75 changed files with 4419 additions and 257 deletions
|
@ -30,6 +30,7 @@ export {
|
|||
defaultAnnotationColor,
|
||||
defaultAnnotationRangeColor,
|
||||
defaultAnnotationLabel,
|
||||
defaultRangeAnnotationLabel,
|
||||
getDefaultManualAnnotation,
|
||||
getDefaultQueryAnnotation,
|
||||
createCopiedAnnotation,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -110,7 +110,7 @@ pageLoadAssetSize:
|
|||
navigation: 37269
|
||||
newsfeed: 42228
|
||||
noDataPage: 5000
|
||||
observability: 76678
|
||||
observability: 118191
|
||||
observabilityAIAssistant: 58230
|
||||
observabilityAIAssistantApp: 27680
|
||||
observabilityAiAssistantManagement: 19279
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.echAnnotation {
|
||||
max-width: 500px;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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;
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
];
|
|
@ -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} />
|
||||
));
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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;
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
];
|
|
@ -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')}</>;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 },
|
||||
}),
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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(', '),
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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]);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 },
|
||||
}),
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
];
|
|
@ -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 };
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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[],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export async function createOrUpdateIndex({
|
|||
logger,
|
||||
}: {
|
||||
index: string;
|
||||
mappings: Mappings;
|
||||
mappings?: Mappings;
|
||||
client: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
}) {
|
||||
|
|
|
@ -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/**/*"
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export * from './slos_overview.journey';
|
||||
export * from './annotation_list.journey';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -99,6 +99,7 @@ export function ErrorBudgetChart({ data, isLoading, slo, selectedTabId, onBrushe
|
|||
data={data}
|
||||
isLoading={isLoading}
|
||||
onBrushed={onBrushed}
|
||||
slo={slo}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue