mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `9.0`: - [feat(slo): update preview data API to show groups (#211801)](https://github.com/elastic/kibana/pull/211801) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Kevin Delemme","email":"kevin.delemme@elastic.co"},"sourceCommit":{"committedDate":"2025-03-04T22:04:10Z","message":"feat(slo): update preview data API to show groups (#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:prev-minor","Team:obs-ux-management","v9.1.0"],"title":"feat(slo): update preview data API to show groups","number":211801,"url":"https://github.com/elastic/kibana/pull/211801","mergeCommit":{"message":"feat(slo): update preview data API to show groups (#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211801","number":211801,"mergeCommit":{"message":"feat(slo): update preview data API to show groups (#211801)","sha":"df59c2608302372273d573b6a9013944361ffeb4"}}]}] BACKPORT-->
This commit is contained in:
parent
4fbbea96bc
commit
c897692644
22 changed files with 4844 additions and 789 deletions
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import * as t from 'io-ts';
|
||||
import { indicatorSchema, objectiveSchema } from '../../schema';
|
||||
import { allOrAnyStringOrArray, dateType } from '../../schema/common';
|
||||
import { dateType, groupingsSchema } from '../../schema/common';
|
||||
|
||||
const getPreviewDataParamsSchema = t.type({
|
||||
body: t.intersection([
|
||||
|
@ -19,32 +19,36 @@ const getPreviewDataParamsSchema = t.type({
|
|||
}),
|
||||
t.partial({
|
||||
objective: objectiveSchema,
|
||||
instanceId: t.string,
|
||||
groupBy: allOrAnyStringOrArray,
|
||||
remoteName: t.string,
|
||||
groupings: t.record(t.string, t.unknown),
|
||||
groupings: groupingsSchema,
|
||||
groupBy: t.array(t.string),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const getPreviewDataResponseSchema = t.array(
|
||||
t.intersection([
|
||||
t.type({
|
||||
date: dateType,
|
||||
sliValue: t.union([t.number, t.null]),
|
||||
const previewDataResponseSchema = t.intersection([
|
||||
t.type({
|
||||
date: dateType,
|
||||
sliValue: t.union([t.number, t.null]),
|
||||
}),
|
||||
t.partial({
|
||||
events: t.type({
|
||||
good: t.number,
|
||||
bad: t.number,
|
||||
total: t.number,
|
||||
}),
|
||||
t.partial({
|
||||
events: t.type({
|
||||
good: t.number,
|
||||
bad: t.number,
|
||||
total: t.number,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
const getPreviewDataResponseSchema = t.intersection([
|
||||
t.type({
|
||||
results: t.array(previewDataResponseSchema),
|
||||
}),
|
||||
t.partial({ groups: t.record(t.string, t.array(previewDataResponseSchema)) }),
|
||||
]);
|
||||
|
||||
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
|
||||
type GetPreviewDataResponse = t.OutputOf<typeof getPreviewDataResponseSchema>;
|
||||
|
||||
export { getPreviewDataParamsSchema, getPreviewDataResponseSchema };
|
||||
export { getPreviewDataParamsSchema, getPreviewDataResponseSchema, previewDataResponseSchema };
|
||||
export type { GetPreviewDataParams, GetPreviewDataResponse };
|
||||
|
|
|
@ -40161,7 +40161,6 @@
|
|||
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "Définir des fenêtres du taux d'avancement multiples",
|
||||
"xpack.slo.burnRates.value": "{value} fois",
|
||||
"xpack.slo.create.errorNotification": "Un problème est survenu lors de la création de {name}",
|
||||
"xpack.slo.dataPreviewChart.noResultsLabel": "aucun résultat",
|
||||
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "Annuler",
|
||||
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "Supprimer le SLO et toutes les instances",
|
||||
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "Supprimer",
|
||||
|
@ -40493,14 +40492,10 @@
|
|||
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "Sélectionner une vue de données",
|
||||
"xpack.slo.sloEdit.customKql.indexSelection.label": "Index",
|
||||
"xpack.slo.sloEdit.customMetric.aggregationLabel": "Agrégation",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "Total des événements",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "Les paramètres d'indicateur actuels ne sont pas valides",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "Remplir les champs d'indicateur pour visualiser les indicateurs actuels",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "Bons événements",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "Certaines des valeurs SLI sont supérieures à 100 %. Cela signifie qu'une bonne requête renvoie plus de résultats que la requête totale.",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "Aperçu du SLI",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "Dernières 24 heures",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "Dernière heure",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
|
||||
"xpack.slo.sloEdit.definition.sliType": "Choisir le type de SLI",
|
||||
"xpack.slo.sloEdit.definition.title": "Définir un SLI",
|
||||
|
|
|
@ -40020,7 +40020,6 @@
|
|||
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "複数のバーンレート時間枠を定義",
|
||||
"xpack.slo.burnRates.value": "{value}x",
|
||||
"xpack.slo.create.errorNotification": "{name}の作成中に問題が発生しました",
|
||||
"xpack.slo.dataPreviewChart.noResultsLabel": "結果なし",
|
||||
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "キャンセル",
|
||||
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "SLOとすべてのインスタンスを削除",
|
||||
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "削除",
|
||||
|
@ -40351,14 +40350,10 @@
|
|||
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "データビューを選択",
|
||||
"xpack.slo.sloEdit.customKql.indexSelection.label": "インデックス",
|
||||
"xpack.slo.sloEdit.customMetric.aggregationLabel": "アグリゲーション",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "全てのイベント",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "現在のインジケーター設定は無効です",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "インジケーターフィールドに入力すると、現在のメトリックが可視化されます。",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "良好なイベント数",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "SLI値の一部が100%を超えています。つまり、質の高いクエリーは合計クエリーよりも多くの結果を返しています。",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "SLIプレビュー",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "過去 24 時間",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "過去 1 時間",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
|
||||
"xpack.slo.sloEdit.definition.sliType": "SLIタイプを選択",
|
||||
"xpack.slo.sloEdit.definition.title": "SLIを定義",
|
||||
|
|
|
@ -39440,7 +39440,6 @@
|
|||
"xpack.slo.burnRateRuleEditor.h5.defineMultipleBurnRateLabel": "定义多个消耗速度窗口",
|
||||
"xpack.slo.burnRates.value": "{value} 倍",
|
||||
"xpack.slo.create.errorNotification": "创建 {name} 时出现问题",
|
||||
"xpack.slo.dataPreviewChart.noResultsLabel": "无结果",
|
||||
"xpack.slo.deleteConfirmationModal.cancelButtonLabel": "取消",
|
||||
"xpack.slo.deleteConfirmationModal.deleteAllButtonLabel": "删除 SLO 和所有实例",
|
||||
"xpack.slo.deleteConfirmationModal.deleteButtonLabel": "删除",
|
||||
|
@ -39769,14 +39768,10 @@
|
|||
"xpack.slo.sloEdit.customKql.dataViewSelection.label": "选择数据视图",
|
||||
"xpack.slo.sloEdit.customKql.indexSelection.label": "索引",
|
||||
"xpack.slo.sloEdit.customMetric.aggregationLabel": "聚合",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.badEvents": "事件合计",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.errorMessage": "当前指标设置无效",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.explanationMessage": "填写指标字段以查看当前指标的可视化",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.goodEvents": "良好事件",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.moreThan100": "某些 SLI 值大于 100%。这表明良好查询返回的结果比总体查询更多。",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.panelLabel": "SLI 预览",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle": "过去 24 小时",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.xTitle": "过去一小时",
|
||||
"xpack.slo.sloEdit.dataPreviewChart.yTitle": "SLI",
|
||||
"xpack.slo.sloEdit.definition.sliType": "选择 SLI 类型",
|
||||
"xpack.slo.sloEdit.definition.title": "定义 SLI",
|
||||
|
|
|
@ -1,192 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import {
|
||||
Axis,
|
||||
BarSeries,
|
||||
Chart,
|
||||
ElementClickListener,
|
||||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
XYChartElementEvent,
|
||||
} from '@elastic/charts';
|
||||
import { EuiIcon, EuiLoadingChart, useEuiTheme } from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { useActiveCursor } from '@kbn/charts-plugin/public';
|
||||
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 '../../pages/slo_details/types';
|
||||
import { getBrushTimeBounds } from '../../utils/slo/duration';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { openInDiscover } from '../../utils/slo/get_discover_link';
|
||||
|
||||
export interface Props {
|
||||
data: GetPreviewDataResponse;
|
||||
slo?: SLOWithSummaryResponse;
|
||||
annotation?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
bottomTitle?: string;
|
||||
onBrushed?: (timeBounds: TimeBounds) => void;
|
||||
}
|
||||
|
||||
export function GoodBadEventsChart({
|
||||
annotation,
|
||||
bottomTitle,
|
||||
data,
|
||||
slo,
|
||||
onBrushed,
|
||||
isLoading = false,
|
||||
}: 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 { ObservabilityAnnotations, annotations, wrapOnBrushEnd, onAnnotationClick } =
|
||||
useAnnotations({
|
||||
slo,
|
||||
});
|
||||
|
||||
const dateFormat = uiSettings.get('dateFormat');
|
||||
|
||||
const yAxisNumberFormat = '0,0';
|
||||
|
||||
const domain = {
|
||||
fit: true,
|
||||
min: NaN,
|
||||
max: NaN,
|
||||
};
|
||||
|
||||
const intervalInMilliseconds =
|
||||
data && data.length > 2
|
||||
? moment(data[1].date).valueOf() - moment(data[0].date).valueOf()
|
||||
: 10 * 60000;
|
||||
|
||||
const goodEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.goodEventsLabel', {
|
||||
defaultMessage: 'Good events',
|
||||
});
|
||||
|
||||
const badEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.badEventsLabel', {
|
||||
defaultMessage: 'Bad events',
|
||||
});
|
||||
|
||||
const barClickHandler = (params: XYChartElementEvent[]) => {
|
||||
if (slo?.indicator?.type === 'sli.kql.custom') {
|
||||
const [datum, eventDetail] = params[0];
|
||||
const isBad = eventDetail.specId === badEventId;
|
||||
const timeRange = {
|
||||
from: moment(datum.x).toISOString(),
|
||||
to: moment(datum.x).add(intervalInMilliseconds, 'ms').toISOString(),
|
||||
mode: 'absolute' as const,
|
||||
};
|
||||
openInDiscover({ slo, showBad: isBad, showGood: !isBad, timeRange, discover, uiSettings });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <EuiLoadingChart size="m" mono data-test-subj="sliEventsChartLoading" />}
|
||||
|
||||
{!isLoading && (
|
||||
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
|
||||
<ObservabilityAnnotations annotations={annotations} />
|
||||
<Settings
|
||||
theme={{
|
||||
chartMargins: { top: 30 },
|
||||
}}
|
||||
baseTheme={baseTheme}
|
||||
showLegend={true}
|
||||
legendPosition={Position.Left}
|
||||
noResults={
|
||||
<EuiIcon
|
||||
type="visualizeApp"
|
||||
size="l"
|
||||
color="subdued"
|
||||
title={i18n.translate('xpack.slo.goodBadEventsChart.euiIcon.noResultsLabel', {
|
||||
defaultMessage: 'no results',
|
||||
})}
|
||||
/>
|
||||
}
|
||||
onPointerUpdate={handleCursorUpdate}
|
||||
externalPointerEvents={{
|
||||
tooltip: { visible: true },
|
||||
}}
|
||||
pointerUpdateDebounce={0}
|
||||
pointerUpdateTrigger={'x'}
|
||||
locale={i18n.getLocale()}
|
||||
onElementClick={barClickHandler as ElementClickListener}
|
||||
onBrushEnd={wrapOnBrushEnd((brushArea) => {
|
||||
onBrushed?.(getBrushTimeBounds(brushArea));
|
||||
})}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
/>
|
||||
{annotation}
|
||||
<Axis
|
||||
id="bottom"
|
||||
title={bottomTitle}
|
||||
position={Position.Bottom}
|
||||
showOverlappingTicks
|
||||
tickFormat={(d) => moment(d).format(dateFormat)}
|
||||
/>
|
||||
<Axis
|
||||
id="left"
|
||||
position={Position.Left}
|
||||
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
|
||||
domain={domain}
|
||||
/>
|
||||
<>
|
||||
<BarSeries
|
||||
id={goodEventId}
|
||||
color={euiTheme.colors.success}
|
||||
barSeriesStyle={{
|
||||
rect: { fill: euiTheme.colors.success },
|
||||
displayValue: { fill: euiTheme.colors.success },
|
||||
}}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="key"
|
||||
yAccessors={['value']}
|
||||
stackAccessors={[0]}
|
||||
data={
|
||||
data?.map((datum) => ({
|
||||
key: new Date(datum.date).getTime(),
|
||||
value: datum.events?.good,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
|
||||
<BarSeries
|
||||
id={badEventId}
|
||||
color={euiTheme.colors.danger}
|
||||
barSeriesStyle={{
|
||||
rect: { fill: euiTheme.colors.danger },
|
||||
displayValue: { fill: euiTheme.colors.danger },
|
||||
}}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="key"
|
||||
yAccessors={['value']}
|
||||
stackAccessors={[0]}
|
||||
data={
|
||||
data?.map((datum) => ({
|
||||
key: new Date(datum.date).getTime(),
|
||||
value: datum.events?.bad,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</Chart>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Indicator } from '@kbn/slo-schema';
|
||||
import type { Indicator, Objective } from '@kbn/slo-schema';
|
||||
|
||||
interface SloListFilter {
|
||||
kqlQuery: string;
|
||||
|
@ -61,11 +61,17 @@ export const sloKeys = {
|
|||
instanceId: string | undefined,
|
||||
windows: Array<{ name: string; duration: string }>
|
||||
) => [...sloKeys.all, 'burnRates', sloId, instanceId, windows] as const,
|
||||
preview: (
|
||||
indicator: Indicator,
|
||||
range: { from: Date; to: Date },
|
||||
groupings?: Record<string, unknown>
|
||||
) => [...sloKeys.all, 'preview', indicator, range, groupings] as const,
|
||||
preview: (params: {
|
||||
remoteName?: string;
|
||||
groupings?: Record<string, string | number>;
|
||||
objective?: Objective;
|
||||
indicator: Indicator;
|
||||
range: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
groupBy?: string[];
|
||||
}) => [...sloKeys.all, 'preview', params] as const,
|
||||
burnRateRules: (search: string) => [...sloKeys.all, 'burnRateRules', search],
|
||||
groupings: (params: {
|
||||
sloId: string;
|
||||
|
|
|
@ -20,16 +20,15 @@ export interface UseGetPreviewData {
|
|||
|
||||
interface Props {
|
||||
isValid: boolean;
|
||||
groupBy?: string | string[];
|
||||
instanceId?: string;
|
||||
remoteName?: string;
|
||||
groupings?: Record<string, unknown>;
|
||||
groupings?: Record<string, string | number>;
|
||||
objective?: Objective;
|
||||
indicator: Indicator;
|
||||
range: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
groupBy?: string[];
|
||||
}
|
||||
|
||||
export function useGetPreviewData({
|
||||
|
@ -37,15 +36,21 @@ export function useGetPreviewData({
|
|||
range,
|
||||
indicator,
|
||||
objective,
|
||||
groupBy,
|
||||
groupings,
|
||||
instanceId,
|
||||
remoteName,
|
||||
groupBy,
|
||||
}: Props): UseGetPreviewData {
|
||||
const { sloClient } = usePluginContext();
|
||||
|
||||
const { isInitialLoading, isLoading, isError, isSuccess, data } = useQuery({
|
||||
queryKey: sloKeys.preview(indicator, range, groupings),
|
||||
queryKey: sloKeys.preview({
|
||||
range,
|
||||
indicator,
|
||||
objective,
|
||||
groupings,
|
||||
remoteName,
|
||||
groupBy,
|
||||
}),
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await sloClient.fetch('POST /internal/observability/slos/_preview', {
|
||||
params: {
|
||||
|
@ -55,17 +60,16 @@ export function useGetPreviewData({
|
|||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
},
|
||||
groupBy,
|
||||
instanceId,
|
||||
groupings,
|
||||
remoteName,
|
||||
...(objective ? { objective } : null),
|
||||
objective,
|
||||
groupBy,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
return Array.isArray(response) ? response : [];
|
||||
return response;
|
||||
},
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
|
@ -1,160 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiLoadingChart,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { max, min } from 'lodash';
|
||||
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 { SloTabId } from './slo_details';
|
||||
import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import { GoodBadEventsChart } from '../../../components/good_bad_events_chart/good_bad_events_chart';
|
||||
import { getDiscoverLink } from '../../../utils/slo/get_discover_link';
|
||||
|
||||
export interface Props {
|
||||
slo: SLOWithSummaryResponse;
|
||||
range: { from: Date; to: Date };
|
||||
selectedTabId: SloTabId;
|
||||
onBrushed?: (timeBounds: TimeBounds) => void;
|
||||
}
|
||||
|
||||
export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props) {
|
||||
const { discover, uiSettings } = useKibana().services;
|
||||
|
||||
const { isLoading, data } = useGetPreviewData({
|
||||
range,
|
||||
isValid: true,
|
||||
groupBy: slo.groupBy,
|
||||
indicator: slo.indicator,
|
||||
groupings: slo.groupings,
|
||||
instanceId: slo.instanceId,
|
||||
remoteName: slo.remote?.remoteName,
|
||||
});
|
||||
|
||||
const title =
|
||||
slo.indicator.type !== 'sli.metric.timeslice' ? (
|
||||
<EuiTitle size="xs">
|
||||
<h2>
|
||||
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.title', {
|
||||
defaultMessage: 'Good vs bad events',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
) : (
|
||||
<EuiTitle size="xs">
|
||||
<h2>
|
||||
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.timesliceTitle', {
|
||||
defaultMessage: 'Timeslice metric',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
);
|
||||
|
||||
const values = (data || []).map((row) => {
|
||||
if (slo.indicator.type === 'sli.metric.timeslice') {
|
||||
return row.sliValue;
|
||||
} else {
|
||||
return row?.events?.total || 0;
|
||||
}
|
||||
});
|
||||
const maxValue = max(values);
|
||||
const minValue = min(values);
|
||||
|
||||
const annotation = <TimesliceAnnotation slo={slo} minValue={minValue} maxValue={maxValue} />;
|
||||
|
||||
const showViewEventsLink = ![
|
||||
'sli.apm.transactionErrorRate',
|
||||
'sli.apm.transactionDuration',
|
||||
].includes(slo.indicator.type);
|
||||
|
||||
return (
|
||||
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="eventsChartPanel">
|
||||
<EuiFlexGroup direction="column" gutterSize="l">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={1}> {title}</EuiFlexItem>
|
||||
{selectedTabId !== 'history' && (
|
||||
<EuiFlexItem>
|
||||
<EuiText color="subdued" size="s">
|
||||
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.duration', {
|
||||
defaultMessage: 'Last 24h',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{showViewEventsLink && (
|
||||
<EuiFlexItem grow={0}>
|
||||
<EuiLink
|
||||
color="text"
|
||||
href={getDiscoverLink({
|
||||
slo,
|
||||
timeRange: {
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
mode: 'relative',
|
||||
},
|
||||
discover,
|
||||
uiSettings,
|
||||
})}
|
||||
data-test-subj="sloDetailDiscoverLink"
|
||||
>
|
||||
<EuiIcon type="sortRight" style={{ marginRight: '4px' }} />
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloDetails.viewEventsLink"
|
||||
defaultMessage="View events"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiFlexItem>
|
||||
{slo.indicator.type !== 'sli.metric.timeslice' ? (
|
||||
<GoodBadEventsChart
|
||||
isLoading={isLoading}
|
||||
data={data || []}
|
||||
annotation={annotation}
|
||||
slo={slo}
|
||||
onBrushed={onBrushed}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<EuiLoadingChart size="m" mono data-test-subj="sliEventsChartLoading" />
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<EventsAreaChart
|
||||
slo={slo}
|
||||
annotation={annotation}
|
||||
minValue={minValue}
|
||||
maxValue={maxValue}
|
||||
onBrushed={onBrushed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiLoadingChart,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import React from 'react';
|
||||
import { useGetPreviewData } from '../../../../hooks/use_get_preview_data';
|
||||
import { useKibana } from '../../../../hooks/use_kibana';
|
||||
import { TimeBounds } from '../../types';
|
||||
import { getDiscoverLink } from '../../utils/get_discover_link';
|
||||
import { GoodBadEventsChart } from './good_bad_events_chart';
|
||||
import { MetricTimesliceEventsChart } from './metric_timeslice_events_chart';
|
||||
|
||||
export interface Props {
|
||||
slo: SLOWithSummaryResponse;
|
||||
range: { from: Date; to: Date };
|
||||
hideRangeDurationLabel?: boolean;
|
||||
onBrushed?: (timeBounds: TimeBounds) => void;
|
||||
}
|
||||
|
||||
export function EventsChartPanel({ slo, range, hideRangeDurationLabel = false, onBrushed }: Props) {
|
||||
const { discover, uiSettings } = useKibana().services;
|
||||
const { isLoading, data } = useGetPreviewData({
|
||||
range,
|
||||
isValid: true,
|
||||
indicator: slo.indicator,
|
||||
groupings: slo.groupings,
|
||||
objective: slo.objective,
|
||||
remoteName: slo.remote?.remoteName,
|
||||
});
|
||||
|
||||
const canLinkToDiscover = ![
|
||||
'sli.apm.transactionErrorRate',
|
||||
'sli.apm.transactionDuration',
|
||||
].includes(slo.indicator.type);
|
||||
|
||||
function getChartTitle() {
|
||||
switch (slo.indicator.type) {
|
||||
case 'sli.metric.timeslice':
|
||||
return i18n.translate('xpack.slo.sloDetails.eventsChartPanel.timesliceTitle', {
|
||||
defaultMessage: 'Timeslice metric',
|
||||
});
|
||||
default:
|
||||
return i18n.translate('xpack.slo.sloDetails.eventsChartPanel.title', {
|
||||
defaultMessage: 'Good vs bad events',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getChart() {
|
||||
if (isLoading) {
|
||||
return <EuiLoadingChart size="m" mono data-test-subj="eventsLoadingChart" />;
|
||||
}
|
||||
|
||||
switch (slo.indicator.type) {
|
||||
case 'sli.metric.timeslice':
|
||||
return (
|
||||
<MetricTimesliceEventsChart slo={slo} data={data?.results ?? []} onBrushed={onBrushed} />
|
||||
);
|
||||
|
||||
default:
|
||||
return <GoodBadEventsChart data={data?.results ?? []} slo={slo} onBrushed={onBrushed} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="eventsChartPanel">
|
||||
<EuiFlexGroup direction="column" gutterSize="l">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xs">
|
||||
<h2>{getChartTitle()}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{!hideRangeDurationLabel && (
|
||||
<EuiFlexItem>
|
||||
<EuiText color="subdued" size="s">
|
||||
{i18n.translate('xpack.slo.sloDetails.eventsChartPanel.duration', {
|
||||
defaultMessage: 'Last 24h',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{canLinkToDiscover && (
|
||||
<EuiFlexItem grow={0}>
|
||||
<EuiLink
|
||||
color="text"
|
||||
href={getDiscoverLink({
|
||||
slo,
|
||||
timeRange: {
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
mode: 'relative',
|
||||
},
|
||||
discover,
|
||||
uiSettings,
|
||||
})}
|
||||
data-test-subj="sloDetailDiscoverLink"
|
||||
>
|
||||
<EuiIcon type="sortRight" style={{ marginRight: '4px' }} />
|
||||
<FormattedMessage
|
||||
id="xpack.slo.sloDetails.viewEventsLink"
|
||||
defaultMessage="View events"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiFlexItem>{getChart()}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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 {
|
||||
Axis,
|
||||
BarSeries,
|
||||
Chart,
|
||||
ElementClickListener,
|
||||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
XYChartElementEvent,
|
||||
} from '@elastic/charts';
|
||||
import { EuiIcon, useEuiTheme } from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { useActiveCursor } from '@kbn/charts-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAnnotations } from '@kbn/observability-plugin/public';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import moment from 'moment';
|
||||
import React, { useRef } from 'react';
|
||||
import { useKibana } from '../../../../hooks/use_kibana';
|
||||
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
|
||||
import { TimeBounds } from '../../types';
|
||||
import { openInDiscover } from '../../utils/get_discover_link';
|
||||
import { GetPreviewDataResponseResults } from './types';
|
||||
|
||||
export interface Props {
|
||||
data: GetPreviewDataResponseResults;
|
||||
slo: SLOWithSummaryResponse;
|
||||
onBrushed?: (timeBounds: TimeBounds) => void;
|
||||
}
|
||||
|
||||
export function GoodBadEventsChart({ data, slo, 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 { ObservabilityAnnotations, annotations, wrapOnBrushEnd, onAnnotationClick } =
|
||||
useAnnotations({ slo });
|
||||
|
||||
const dateFormat = uiSettings.get('dateFormat');
|
||||
|
||||
const intervalInMilliseconds =
|
||||
data && data.length > 2
|
||||
? moment(data[1].date).valueOf() - moment(data[0].date).valueOf()
|
||||
: 10 * 60000;
|
||||
|
||||
const goodEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.goodEventsLabel', {
|
||||
defaultMessage: 'Good events',
|
||||
});
|
||||
|
||||
const badEventId = i18n.translate('xpack.slo.sloDetails.eventsChartPanel.badEventsLabel', {
|
||||
defaultMessage: 'Bad events',
|
||||
});
|
||||
|
||||
const barClickHandler = (params: XYChartElementEvent[]) => {
|
||||
const [datum, eventDetail] = params[0];
|
||||
const isBad = eventDetail.specId === badEventId;
|
||||
const timeRange = {
|
||||
from: moment(datum.x).toISOString(),
|
||||
to: moment(datum.x).add(intervalInMilliseconds, 'ms').toISOString(),
|
||||
mode: 'absolute' as const,
|
||||
};
|
||||
openInDiscover({ slo, showBad: isBad, showGood: !isBad, timeRange, discover, uiSettings });
|
||||
};
|
||||
|
||||
return (
|
||||
<Chart size={{ height: 200, width: '100%' }} ref={chartRef}>
|
||||
<ObservabilityAnnotations annotations={annotations} />
|
||||
<Settings
|
||||
theme={{
|
||||
chartMargins: { top: 30 },
|
||||
}}
|
||||
baseTheme={baseTheme}
|
||||
showLegend={true}
|
||||
legendPosition={Position.Left}
|
||||
noResults={
|
||||
<EuiIcon
|
||||
type="visualizeApp"
|
||||
size="l"
|
||||
color="subdued"
|
||||
title={i18n.translate('xpack.slo.goodBadEventsChart.euiIcon.noResultsLabel', {
|
||||
defaultMessage: 'no results',
|
||||
})}
|
||||
/>
|
||||
}
|
||||
onPointerUpdate={handleCursorUpdate}
|
||||
externalPointerEvents={{
|
||||
tooltip: { visible: true },
|
||||
}}
|
||||
pointerUpdateDebounce={0}
|
||||
pointerUpdateTrigger={'x'}
|
||||
locale={i18n.getLocale()}
|
||||
onElementClick={barClickHandler as ElementClickListener}
|
||||
onBrushEnd={wrapOnBrushEnd((brushArea) => {
|
||||
onBrushed?.(getBrushTimeBounds(brushArea));
|
||||
})}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
/>
|
||||
<Axis
|
||||
id="bottom"
|
||||
position={Position.Bottom}
|
||||
showOverlappingTicks
|
||||
tickFormat={(d) => moment(d).format(dateFormat)}
|
||||
/>
|
||||
<Axis
|
||||
id="left"
|
||||
position={Position.Left}
|
||||
tickFormat={(d) => numeral(d).format('0')}
|
||||
domain={{
|
||||
fit: true,
|
||||
min: NaN,
|
||||
max: NaN,
|
||||
}}
|
||||
/>
|
||||
|
||||
<BarSeries
|
||||
id={goodEventId}
|
||||
color={euiTheme.colors.success}
|
||||
barSeriesStyle={{
|
||||
rect: { fill: euiTheme.colors.success },
|
||||
displayValue: { fill: euiTheme.colors.success },
|
||||
}}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="key"
|
||||
yAccessors={['value']}
|
||||
stackAccessors={[0]}
|
||||
data={data.map((datum) => ({
|
||||
key: new Date(datum.date).getTime(),
|
||||
value: datum.events?.good,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<BarSeries
|
||||
id={badEventId}
|
||||
color={euiTheme.colors.danger}
|
||||
barSeriesStyle={{
|
||||
rect: { fill: euiTheme.colors.danger },
|
||||
displayValue: { fill: euiTheme.colors.danger },
|
||||
}}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="key"
|
||||
yAccessors={['value']}
|
||||
stackAccessors={[0]}
|
||||
data={data.map((datum) => ({
|
||||
key: new Date(datum.date).getTime(),
|
||||
value: datum.events?.bad,
|
||||
}))}
|
||||
/>
|
||||
</Chart>
|
||||
);
|
||||
}
|
|
@ -5,28 +5,27 @@
|
|||
* 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';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import React from 'react';
|
||||
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) {
|
||||
export function MetricTimesliceAnnotation({ slo, maxValue, minValue }: Props) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const threshold =
|
||||
slo.indicator.type === 'sli.metric.timeslice'
|
||||
? slo.indicator.params.metric.threshold
|
||||
: undefined;
|
||||
if (slo.indicator.type !== 'sli.metric.timeslice') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return slo.indicator.type === 'sli.metric.timeslice' && threshold ? (
|
||||
const threshold = slo.indicator.params.metric.threshold;
|
||||
return (
|
||||
<>
|
||||
<LineAnnotation
|
||||
id="thresholdAnnotation"
|
||||
|
@ -46,10 +45,7 @@ export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
|
|||
dataValues={[
|
||||
{
|
||||
coordinates: ['GT', 'GTE'].includes(slo.indicator.params.metric.comparator)
|
||||
? {
|
||||
y0: threshold,
|
||||
y1: maxValue,
|
||||
}
|
||||
? { y0: threshold, y1: maxValue }
|
||||
: { y0: minValue, y1: threshold },
|
||||
details: `${COMPARATOR_MAPPING[slo.indicator.params.metric.comparator]} ${threshold}`,
|
||||
},
|
||||
|
@ -58,5 +54,5 @@ export function TimesliceAnnotation({ slo, maxValue, minValue }: Props) {
|
|||
style={{ fill: euiTheme.colors.warning, opacity: 0.1 }}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
}
|
|
@ -7,60 +7,53 @@
|
|||
|
||||
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 { i18n } from '@kbn/i18n';
|
||||
import { useAnnotations } from '@kbn/observability-plugin/public';
|
||||
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { max, min } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { getBrushTimeBounds } from '../../../utils/slo/duration';
|
||||
import { TimeBounds } from '../types';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import React, { useRef } from 'react';
|
||||
import { useKibana } from '../../../../hooks/use_kibana';
|
||||
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
|
||||
import { TimeBounds } from '../../types';
|
||||
import { MetricTimesliceAnnotation } from './metric_timeslice_annotation';
|
||||
import { GetPreviewDataResponseResults } from './types';
|
||||
|
||||
export function EventsAreaChart({
|
||||
slo,
|
||||
data,
|
||||
minValue,
|
||||
maxValue,
|
||||
annotation,
|
||||
onBrushed,
|
||||
}: {
|
||||
data?: GetPreviewDataResponse;
|
||||
maxValue?: number | null;
|
||||
minValue?: number | null;
|
||||
interface Props {
|
||||
data: GetPreviewDataResponseResults;
|
||||
slo: SLOWithSummaryResponse;
|
||||
annotation?: React.ReactNode;
|
||||
onBrushed?: (timeBounds: TimeBounds) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function MetricTimesliceEventsChart({ slo, data, onBrushed }: Props) {
|
||||
const { charts, uiSettings } = useKibana().services;
|
||||
const chartRef = useRef(null);
|
||||
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 { ObservabilityAnnotations, annotations, wrapOnBrushEnd } = useAnnotations({
|
||||
slo,
|
||||
});
|
||||
|
||||
const threshold =
|
||||
slo.indicator.type === 'sli.metric.timeslice'
|
||||
? slo.indicator.params.metric.threshold
|
||||
: undefined;
|
||||
if (slo.indicator.type !== 'sli.metric.timeslice') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = data.map((row) => row.sliValue);
|
||||
const maxValue = max(values);
|
||||
const minValue = min(values);
|
||||
const threshold = slo.indicator.params.metric.threshold;
|
||||
|
||||
const domain = {
|
||||
fit: true,
|
||||
min:
|
||||
threshold != null && minValue != null && threshold < minValue ? threshold : minValue || NaN,
|
||||
max:
|
||||
threshold != null && maxValue != null && threshold > maxValue ? threshold : maxValue || NaN,
|
||||
min: min([threshold, minValue]) ?? NaN,
|
||||
max: max([threshold, maxValue]) ?? NaN,
|
||||
};
|
||||
|
||||
const { ObservabilityAnnotations, annotations, wrapOnBrushEnd } = useAnnotations({
|
||||
domain,
|
||||
slo,
|
||||
});
|
||||
|
||||
return (
|
||||
<Chart size={{ height: 150, width: '100%' }} ref={chartRef}>
|
||||
<ObservabilityAnnotations annotations={annotations} />
|
||||
|
@ -89,8 +82,7 @@ export function EventsAreaChart({
|
|||
onBrushed?.(getBrushTimeBounds(brushArea));
|
||||
})}
|
||||
/>
|
||||
{annotation}
|
||||
|
||||
<MetricTimesliceAnnotation slo={slo} minValue={minValue} maxValue={maxValue} />
|
||||
<Axis
|
||||
id="bottom"
|
||||
position={Position.Bottom}
|
||||
|
@ -100,7 +92,7 @@ export function EventsAreaChart({
|
|||
<Axis
|
||||
id="left"
|
||||
position={Position.Left}
|
||||
tickFormat={(d) => numeral(d).format(yAxisNumberFormat)}
|
||||
tickFormat={(d) => numeral(d).format('0,0[.00]')}
|
||||
domain={domain}
|
||||
/>
|
||||
<AreaSeries
|
||||
|
@ -109,7 +101,7 @@ export function EventsAreaChart({
|
|||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="date"
|
||||
yAccessors={['value']}
|
||||
data={(data ?? []).map((datum) => ({
|
||||
data={data.map((datum) => ({
|
||||
date: new Date(datum.date).getTime(),
|
||||
value: datum.sliValue,
|
||||
}))}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { GetPreviewDataResponse } from '@kbn/slo-schema';
|
||||
|
||||
export type GetPreviewDataResponseResults = GetPreviewDataResponse['results'];
|
|
@ -20,7 +20,7 @@ import React, { useMemo, useState } from 'react';
|
|||
import { ErrorRateChart } from '../../../../components/slo/error_rate_chart';
|
||||
import { useKibana } from '../../../../hooks/use_kibana';
|
||||
import { TimeBounds } from '../../types';
|
||||
import { EventsChartPanel } from '../events_chart_panel';
|
||||
import { EventsChartPanel } from '../events_chart_panel/events_chart_panel';
|
||||
import { HistoricalDataCharts } from '../historical_data_charts';
|
||||
import { SloTabId } from '../slo_details';
|
||||
|
||||
|
@ -111,12 +111,7 @@ export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Prop
|
|||
onBrushed={onBrushed}
|
||||
/>
|
||||
|
||||
<EventsChartPanel
|
||||
slo={slo}
|
||||
range={range}
|
||||
selectedTabId={selectedTabId}
|
||||
onBrushed={onBrushed}
|
||||
/>
|
||||
<EventsChartPanel slo={slo} range={range} hideRangeDurationLabel onBrushed={onBrushed} />
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
|||
import moment from 'moment';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BurnRatePanel } from './burn_rate_panel/burn_rate_panel';
|
||||
import { EventsChartPanel } from './events_chart_panel';
|
||||
import { EventsChartPanel } from './events_chart_panel/events_chart_panel';
|
||||
import { HistoricalDataCharts } from './historical_data_charts';
|
||||
import { SLODetailsHistory } from './history/slo_details_history';
|
||||
import { Overview } from './overview/overview';
|
||||
|
@ -76,7 +76,7 @@ export function SloDetails({ slo, isAutoRefreshing, selectedTabId }: Props) {
|
|||
isAutoRefreshing={isAutoRefreshing}
|
||||
/>
|
||||
|
||||
<EventsChartPanel slo={slo} range={range} selectedTabId={selectedTabId} />
|
||||
<EventsChartPanel slo={slo} range={range} />
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
|
|
@ -4,15 +4,15 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/public';
|
||||
import { DiscoverStart } from '@kbn/discover-plugin/public';
|
||||
import { ALL_VALUE, kqlWithFiltersSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { Filter, FilterStateStore, TimeRange } from '@kbn/es-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { buildEsQuery } from '@kbn/observability-plugin/public';
|
||||
import { v4 } from 'uuid';
|
||||
import { kqlWithFiltersSchema, SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/public';
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
function createDiscoverLocator({
|
||||
slo,
|
||||
|
@ -106,49 +106,40 @@ function createDiscoverLocator({
|
|||
});
|
||||
}
|
||||
|
||||
const groupBy = [slo.groupBy].flat();
|
||||
if (!isEmpty(slo.groupings)) {
|
||||
const groupingKeys = Object.keys(slo.groupings);
|
||||
const groupingFilters = groupingKeys.map((key) => ({
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
key,
|
||||
params: {
|
||||
query: slo.groupings[key],
|
||||
},
|
||||
type: 'phrase',
|
||||
index: indexId,
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
[key]: slo.groupings[key],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
!isEmpty(slo.groupings) &&
|
||||
groupBy.length > 0 &&
|
||||
groupBy.every((field) => field === ALL_VALUE) === false
|
||||
) {
|
||||
groupBy.forEach((field) => {
|
||||
filters.push({
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
key: field,
|
||||
params: {
|
||||
query: slo.groupings[field],
|
||||
},
|
||||
type: 'phrase',
|
||||
index: indexId,
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
[field]: slo.groupings[field],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
filters.push(...groupingFilters);
|
||||
}
|
||||
|
||||
const timeFieldName =
|
||||
slo.indicator.type !== 'sli.apm.transactionDuration' &&
|
||||
slo.indicator.type !== 'sli.apm.transactionErrorRate' &&
|
||||
slo.indicator.type !== 'sli.synthetics.availability'
|
||||
? slo.indicator.params.timestampField
|
||||
: '@timestamp';
|
||||
'timestampField' in slo.indicator.params ? slo.indicator.params.timestampField : '@timestamp';
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
query: {
|
||||
query: filterKuery || '',
|
||||
query: filterKuery ?? '',
|
||||
language: 'kuery',
|
||||
},
|
||||
filters,
|
|
@ -7,10 +7,10 @@
|
|||
|
||||
import {
|
||||
AnnotationDomainType,
|
||||
AreaSeries,
|
||||
Axis,
|
||||
Chart,
|
||||
LineAnnotation,
|
||||
LineSeries,
|
||||
Position,
|
||||
RectAnnotation,
|
||||
ScaleType,
|
||||
|
@ -24,7 +24,6 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiLoadingChart,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
|
@ -33,12 +32,12 @@ import {
|
|||
import numeral from '@elastic/numeral';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { max, min } from 'lodash';
|
||||
import { GetPreviewDataResponse } from '@kbn/slo-schema';
|
||||
import { map, max, min, values } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React, { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useKibana } from '../../../../hooks/use_kibana';
|
||||
import { GoodBadEventsChart } from '../../../../components/good_bad_events_chart/good_bad_events_chart';
|
||||
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
|
||||
import { useSectionFormValidation } from '../../hooks/use_section_form_validation';
|
||||
import { CreateSLOForm } from '../../types';
|
||||
|
@ -50,24 +49,16 @@ interface DataPreviewChartProps {
|
|||
thresholdColor?: string;
|
||||
thresholdMessage?: string;
|
||||
ignoreMoreThan100?: boolean;
|
||||
useGoodBadEventsChart?: boolean;
|
||||
label?: string;
|
||||
range?: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataPreviewChart({
|
||||
formatPattern,
|
||||
// Specific to timeslice metric indicator type
|
||||
threshold,
|
||||
thresholdDirection,
|
||||
thresholdColor,
|
||||
thresholdMessage,
|
||||
ignoreMoreThan100,
|
||||
label,
|
||||
useGoodBadEventsChart,
|
||||
range,
|
||||
}: DataPreviewChartProps) {
|
||||
const { watch, getFieldState, formState, getValues } = useFormContext<CreateSLOForm>();
|
||||
const { charts, uiSettings } = useKibana().services;
|
||||
|
@ -78,22 +69,24 @@ export function DataPreviewChart({
|
|||
watch,
|
||||
});
|
||||
|
||||
const [defaultRange, _] = useState({
|
||||
from: moment().subtract(1, 'hour').toDate(),
|
||||
const [range, _] = useState({
|
||||
from: moment().subtract(1, 'day').toDate(),
|
||||
to: new Date(),
|
||||
});
|
||||
|
||||
const indicator = watch('indicator');
|
||||
const groupBy = watch('groupBy');
|
||||
|
||||
const {
|
||||
data: previewData,
|
||||
isLoading: isPreviewLoading,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range ?? defaultRange);
|
||||
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range, groupBy);
|
||||
|
||||
const isMoreThan100 =
|
||||
!ignoreMoreThan100 && previewData?.find((row) => row.sliValue && row.sliValue > 1) != null;
|
||||
!ignoreMoreThan100 &&
|
||||
previewData?.results.some((datum) => datum.sliValue && datum.sliValue > 1);
|
||||
|
||||
const baseTheme = charts.theme.useChartsBaseTheme();
|
||||
const dateFormat = uiSettings.get('dateFormat');
|
||||
|
@ -102,17 +95,7 @@ export function DataPreviewChart({
|
|||
? formatPattern
|
||||
: (uiSettings.get('format:percent:defaultPattern') as string);
|
||||
|
||||
// map values to row.sliValue and filter out no data values
|
||||
const values = (previewData || []).map((row) => row.sliValue);
|
||||
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 { maxValue, minValue, domain } = getChartDomain(previewData, threshold);
|
||||
|
||||
const title = (
|
||||
<>
|
||||
|
@ -157,7 +140,7 @@ export function DataPreviewChart({
|
|||
style={{
|
||||
line: {
|
||||
strokeWidth: 2,
|
||||
stroke: thresholdColor || '#000',
|
||||
stroke: thresholdColor ?? '#000',
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
|
@ -167,16 +150,13 @@ export function DataPreviewChart({
|
|||
{
|
||||
coordinates:
|
||||
thresholdDirection === 'above'
|
||||
? {
|
||||
y0: threshold,
|
||||
y1: maxValue,
|
||||
}
|
||||
? { y0: threshold, y1: maxValue }
|
||||
: { y0: minValue, y1: threshold },
|
||||
details: thresholdMessage,
|
||||
},
|
||||
]}
|
||||
id="thresholdShade"
|
||||
style={{ fill: thresholdColor || '#000', opacity: 0.1 }}
|
||||
style={{ fill: thresholdColor ?? '#000', opacity: 0.1 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -187,27 +167,53 @@ export function DataPreviewChart({
|
|||
type: 'color',
|
||||
},
|
||||
{
|
||||
id: 'label',
|
||||
id: 'group',
|
||||
type: 'custom',
|
||||
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.groupLabel', {
|
||||
defaultMessage: 'Group',
|
||||
}),
|
||||
truncate: true,
|
||||
cell: ({ label: cellLabel }) => <span className="echTooltip__label">{cellLabel}</span>,
|
||||
style: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
style: { textAlign: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
id: 'sli',
|
||||
type: 'custom',
|
||||
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.sliLabel', {
|
||||
defaultMessage: 'SLI',
|
||||
}),
|
||||
cell: ({ formattedValue }) => (
|
||||
<>
|
||||
<span className="echTooltip__value" dir="ltr">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</>
|
||||
<span className="echTooltip__value" dir="ltr">
|
||||
{formattedValue}
|
||||
</span>
|
||||
),
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
style: { textAlign: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'good',
|
||||
type: 'custom',
|
||||
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.goodEventsLabel', {
|
||||
defaultMessage: 'Good events',
|
||||
}),
|
||||
cell: ({ datum }) => (
|
||||
<span className="echTooltip__value" dir="ltr">
|
||||
{datum.events?.good ?? '-'}
|
||||
</span>
|
||||
),
|
||||
style: { textAlign: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
type: 'custom',
|
||||
header: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.tooltip.totalEventsLabel', {
|
||||
defaultMessage: 'Total events',
|
||||
}),
|
||||
cell: ({ datum }) => (
|
||||
<span className="echTooltip__value" dir="ltr">
|
||||
{datum.events?.total ?? '-'}
|
||||
</span>
|
||||
),
|
||||
style: { textAlign: 'right' },
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -231,10 +237,10 @@ export function DataPreviewChart({
|
|||
)}
|
||||
<EuiFormRow fullWidth>
|
||||
<EuiPanel hasBorder={true} hasShadow={false} style={{ minHeight: 194 }}>
|
||||
{(isPreviewLoading || isError) && (
|
||||
{(isLoading || isError) && (
|
||||
<EuiFlexGroup justifyContent="center" alignItems="center" style={{ height: 160 }}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{isPreviewLoading && <EuiLoadingChart size="m" mono />}
|
||||
{isLoading && <EuiLoadingChart size="m" mono />}
|
||||
{isError && (
|
||||
<span>
|
||||
{i18n.translate('xpack.slo.sloEdit.dataPreviewChart.errorMessage', {
|
||||
|
@ -245,71 +251,27 @@ export function DataPreviewChart({
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{isSuccess && useGoodBadEventsChart && (
|
||||
<GoodBadEventsChart
|
||||
data={previewData || []}
|
||||
bottomTitle={label || DEFAULT_LABEL}
|
||||
isLoading={isPreviewLoading}
|
||||
annotation={annotation}
|
||||
/>
|
||||
)}
|
||||
{isSuccess && !useGoodBadEventsChart && (
|
||||
|
||||
{isSuccess && (
|
||||
<Chart size={{ height: 160, width: '100%' }}>
|
||||
<Tooltip
|
||||
type="vertical"
|
||||
body={({ items }) => {
|
||||
const firstItem = items[0];
|
||||
const events = firstItem.datum.events;
|
||||
const rows = [items[0]];
|
||||
if (events) {
|
||||
rows.push({
|
||||
...firstItem,
|
||||
formattedValue: events.good,
|
||||
value: events.good,
|
||||
label: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.goodEvents', {
|
||||
defaultMessage: 'Good events',
|
||||
}),
|
||||
});
|
||||
rows.push({
|
||||
...firstItem,
|
||||
value: events.total,
|
||||
formattedValue: events.total,
|
||||
label: i18n.translate('xpack.slo.sloEdit.dataPreviewChart.badEvents', {
|
||||
defaultMessage: 'Total events',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return <TooltipTable columns={columns} items={rows} />;
|
||||
return <TooltipTable columns={columns} items={items} />;
|
||||
}}
|
||||
/>
|
||||
<Settings
|
||||
baseTheme={baseTheme}
|
||||
showLegend={false}
|
||||
theme={[
|
||||
{
|
||||
lineSeriesStyle: {
|
||||
point: { visible: 'never' },
|
||||
},
|
||||
},
|
||||
]}
|
||||
noResults={
|
||||
<EuiIcon
|
||||
type="visualizeApp"
|
||||
size="l"
|
||||
color="subdued"
|
||||
title={i18n.translate('xpack.slo.dataPreviewChart.noResultsLabel', {
|
||||
defaultMessage: 'no results',
|
||||
})}
|
||||
/>
|
||||
}
|
||||
showLegend={true}
|
||||
legendPosition={Position.Right}
|
||||
theme={[{ lineSeriesStyle: { point: { visible: 'never' } } }]}
|
||||
locale={i18n.getLocale()}
|
||||
/>
|
||||
|
||||
{annotation}
|
||||
|
||||
<Axis
|
||||
id="y-axis"
|
||||
id="value"
|
||||
title={i18n.translate('xpack.slo.sloEdit.dataPreviewChart.yTitle', {
|
||||
defaultMessage: 'SLI',
|
||||
})}
|
||||
|
@ -318,10 +280,11 @@ export function DataPreviewChart({
|
|||
tickFormat={(d) => numeral(d).format(numberFormat)}
|
||||
domain={domain}
|
||||
/>
|
||||
|
||||
<Axis
|
||||
id="time"
|
||||
title={label || DEFAULT_LABEL}
|
||||
title={i18n.translate('xpack.slo.sloEdit.dataPreviewChart.xTitle', {
|
||||
defaultMessage: 'Last 24 hours',
|
||||
})}
|
||||
tickFormat={(d) => moment(d).format(dateFormat)}
|
||||
position={Position.Bottom}
|
||||
timeAxisLayerCount={2}
|
||||
|
@ -338,18 +301,35 @@ export function DataPreviewChart({
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<AreaSeries
|
||||
id="SLI"
|
||||
|
||||
<LineSeries
|
||||
id="All groups"
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="date"
|
||||
yAccessors={['value']}
|
||||
data={(previewData ?? []).map((datum) => ({
|
||||
data={(previewData?.results ?? []).map((datum) => ({
|
||||
date: new Date(datum.date).getTime(),
|
||||
value: datum.sliValue && datum.sliValue >= 0 ? datum.sliValue : null,
|
||||
events: datum.events,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{map(previewData?.groups, (data, group) => (
|
||||
<LineSeries
|
||||
key={group}
|
||||
id={group}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="date"
|
||||
yAccessors={['value']}
|
||||
data={data.map((datum) => ({
|
||||
date: new Date(datum.date).getTime(),
|
||||
value: datum.sliValue && datum.sliValue >= 0 ? datum.sliValue : null,
|
||||
events: datum.events,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
</Chart>
|
||||
)}
|
||||
</EuiPanel>
|
||||
|
@ -358,6 +338,17 @@ export function DataPreviewChart({
|
|||
);
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL = i18n.translate('xpack.slo.sloEdit.dataPreviewChart.xTitle', {
|
||||
defaultMessage: 'Last hour',
|
||||
});
|
||||
function getChartDomain(previewData?: GetPreviewDataResponse, threshold?: number) {
|
||||
const allGroupsValues = map(previewData?.results, (datum) => datum.sliValue);
|
||||
const groupsValues = values(previewData?.groups)
|
||||
.flat()
|
||||
.map((datum) => datum.sliValue);
|
||||
const maxValue = max(allGroupsValues.concat(groupsValues));
|
||||
const minValue = min(allGroupsValues.concat(groupsValues));
|
||||
const domain = {
|
||||
fit: true,
|
||||
min: min([threshold, minValue]) ?? NaN,
|
||||
max: max([threshold, maxValue]) ?? NaN,
|
||||
};
|
||||
return { maxValue, minValue, domain };
|
||||
}
|
||||
|
|
|
@ -13,16 +13,15 @@ import {
|
|||
QuerySchema,
|
||||
SyntheticsAvailabilityIndicator,
|
||||
} from '@kbn/slo-schema';
|
||||
import moment from 'moment';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
|
||||
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
|
||||
import { formatAllFilters } from '../../../helpers/format_filters';
|
||||
import { CreateSLOForm } from '../../../types';
|
||||
import { DataPreviewChart } from '../../common/data_preview_chart';
|
||||
import { GroupByCardinality } from '../../common/group_by_cardinality';
|
||||
import { QueryBuilder } from '../../common/query_builder';
|
||||
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
|
||||
import { FieldSelector } from '../synthetics_common/field_selector';
|
||||
|
||||
export function SyntheticsAvailabilityIndicatorTypeForm() {
|
||||
|
@ -43,11 +42,6 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
|
|||
dataViewId,
|
||||
});
|
||||
|
||||
const [range, _] = useState({
|
||||
from: moment().subtract(1, 'day').toDate(),
|
||||
to: new Date(),
|
||||
});
|
||||
|
||||
const filters = {
|
||||
monitorIds: monitorIds.map((id) => id.value).filter((id) => id !== ALL_VALUE),
|
||||
projects: projects.map((project) => project.value).filter((id) => id !== ALL_VALUE),
|
||||
|
@ -164,15 +158,11 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
|
|||
})}
|
||||
customFilters={allFilters as QuerySchema}
|
||||
/>
|
||||
<DataPreviewChart range={range} label={LABEL} useGoodBadEventsChart />
|
||||
<DataPreviewChart />
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const LABEL = i18n.translate('xpack.slo.sloEdit.dataPreviewChart.syntheticsAvailability.xTitle', {
|
||||
defaultMessage: 'Last 24 hours',
|
||||
});
|
||||
|
||||
export const getGroupByCardinalityFilters = (
|
||||
monitorIds: string[],
|
||||
projects: string[],
|
||||
|
|
|
@ -13,25 +13,42 @@ import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
|
|||
export function useDebouncedGetPreviewData(
|
||||
isIndicatorValid: boolean,
|
||||
indicator: Indicator,
|
||||
range: { from: Date; to: Date }
|
||||
range: { from: Date; to: Date },
|
||||
groupBy?: string | string[]
|
||||
) {
|
||||
const serializedIndicator = JSON.stringify(indicator);
|
||||
const [indicatorState, setIndicatorState] = useState<string>(serializedIndicator);
|
||||
|
||||
const serializedGroupBy = JSON.stringify([groupBy].flat());
|
||||
const [groupByState, setGroupByState] = useState<string>(serializedGroupBy);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const store = useCallback(
|
||||
debounce((value: string) => setIndicatorState(value), 800),
|
||||
[]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const storeGroupBy = useCallback(
|
||||
debounce((value: string) => setGroupByState(value), 800),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorState !== serializedIndicator) {
|
||||
store(serializedIndicator);
|
||||
}
|
||||
}, [indicatorState, serializedIndicator, store]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupByState !== serializedGroupBy) {
|
||||
storeGroupBy(serializedGroupBy);
|
||||
}
|
||||
}, [groupByState, serializedGroupBy, storeGroupBy]);
|
||||
|
||||
return useGetPreviewData({
|
||||
isValid: isIndicatorValid,
|
||||
indicator: JSON.parse(indicatorState),
|
||||
range,
|
||||
groupBy: JSON.parse(groupByState),
|
||||
});
|
||||
}
|
||||
|
|
3600
x-pack/solutions/observability/plugins/slo/server/services/__snapshots__/get_preview_data.test.ts.snap
generated
Normal file
3600
x-pack/solutions/observability/plugins/slo/server/services/__snapshots__/get_preview_data.test.ts.snap
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,335 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { dataViewsService } from '@kbn/data-views-plugin/server/mocks';
|
||||
import { GetPreviewDataParams } from '@kbn/slo-schema';
|
||||
import { GetPreviewData } from './get_preview_data';
|
||||
import { oneMinute } from './fixtures/duration';
|
||||
|
||||
describe('GetPreviewData', () => {
|
||||
let esClientMock: ElasticsearchClientMock;
|
||||
let service: GetPreviewData;
|
||||
|
||||
beforeEach(() => {
|
||||
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
|
||||
service = new GetPreviewData(esClientMock, 'default', dataViewsService);
|
||||
});
|
||||
|
||||
describe("for 'Custom KQL' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.kql.custom',
|
||||
params: {
|
||||
index: 'kbn-data-forge-fake_stack.admin-console-*',
|
||||
filter: 'http.response.status_code :*',
|
||||
good: 'http.response.status_code <500',
|
||||
total: 'http.response.status_code :*',
|
||||
timestampField: '@timestamp',
|
||||
dataViewId: 'e7744dbe-a7a4-457b-83aa-539e9c88764c',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'Custom Metric' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.metric.custom',
|
||||
params: {
|
||||
index: 'kbn-data-forge-fake_stack.message_processor-*',
|
||||
filter: '',
|
||||
good: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'A',
|
||||
aggregation: 'sum',
|
||||
field: 'processor.processed',
|
||||
filter: '',
|
||||
},
|
||||
],
|
||||
equation: 'A',
|
||||
},
|
||||
total: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'A',
|
||||
aggregation: 'sum',
|
||||
field: 'processor.accepted',
|
||||
filter: '',
|
||||
},
|
||||
],
|
||||
equation: 'A',
|
||||
},
|
||||
timestampField: '@timestamp',
|
||||
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'Custom Histogram' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.histogram.custom',
|
||||
params: {
|
||||
index: 'kbn-data-forge-fake_stack.message_processor-*',
|
||||
timestampField: '@timestamp',
|
||||
filter: '',
|
||||
good: {
|
||||
field: 'processor.latency',
|
||||
aggregation: 'range',
|
||||
filter: '',
|
||||
from: 0,
|
||||
to: 100,
|
||||
},
|
||||
total: {
|
||||
field: 'processor.latency',
|
||||
aggregation: 'value_count',
|
||||
filter: '',
|
||||
},
|
||||
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'Timeslice Metric' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.metric.timeslice',
|
||||
params: {
|
||||
index: 'kbn-data-forge-fake_stack.message_processor-*',
|
||||
filter: '',
|
||||
metric: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'A',
|
||||
aggregation: 'sum',
|
||||
field: 'processor.timeSpent',
|
||||
filter: '',
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
aggregation: 'sum',
|
||||
field: 'processor.processed',
|
||||
filter: '',
|
||||
},
|
||||
],
|
||||
equation: 'A / B / 1000',
|
||||
comparator: 'LTE',
|
||||
threshold: 180,
|
||||
},
|
||||
timestampField: '@timestamp',
|
||||
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
|
||||
},
|
||||
},
|
||||
objective: {
|
||||
target: 0.99,
|
||||
timesliceTarget: 0.95,
|
||||
timesliceWindow: oneMinute(),
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'APM Latency' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.apm.transactionDuration',
|
||||
params: {
|
||||
service: 'frontend',
|
||||
environment: 'prod',
|
||||
transactionType: 'request',
|
||||
transactionName: 'GET /api',
|
||||
threshold: 250,
|
||||
filter: 'some.lable:foo',
|
||||
index:
|
||||
'remote_cluster:apm-*,remote_cluster:metrics-apm*,remote_cluster:metrics-*.otel-*,apm-*,metrics-apm*,metrics-*.otel-*',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'APM Availability' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.apm.transactionErrorRate',
|
||||
params: {
|
||||
service: 'frontend',
|
||||
environment: 'prod',
|
||||
transactionType: 'request',
|
||||
transactionName: 'GET /api',
|
||||
filter: 'some.lable:bar',
|
||||
index:
|
||||
'remote_cluster:apm-*,remote_cluster:metrics-apm*,remote_cluster:metrics-*.otel-*,apm-*,metrics-apm*,metrics-*.otel-*',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for 'Synthetics' indicator type", () => {
|
||||
const params: GetPreviewDataParams = {
|
||||
indicator: {
|
||||
type: 'sli.synthetics.availability',
|
||||
params: {
|
||||
monitorIds: [
|
||||
{ value: 'monitor-1', label: 'My monitor 1' },
|
||||
{ value: 'monitor-2', label: 'My monitor 2' },
|
||||
],
|
||||
index: 'synthetics-*',
|
||||
projects: [{ value: 'project-1', label: 'Project 1' }],
|
||||
tags: [{ value: 'tag-1', label: 'Tag 1' }],
|
||||
dataViewId: '593f894a-3378-42cc-bafc-61b4877b64b0',
|
||||
},
|
||||
},
|
||||
range: {
|
||||
from: new Date('2025-02-23T14:26:49.056Z'),
|
||||
to: new Date('2025-02-24T14:26:49.056Z'),
|
||||
},
|
||||
};
|
||||
it('builds the query without groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['*'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 1 group', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query with 2 groups', async () => {
|
||||
await service.execute({ ...params, groupBy: ['host.name', 'event.action'] });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
it('builds the query for a set of groupings', async () => {
|
||||
await service.execute({ ...params, groupings: { 'host.name': 'host.001', env: 'prod' } });
|
||||
expect(esClientMock.search).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,35 +5,35 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { estypes } from '@elastic/elasticsearch';
|
||||
import { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { calculateAuto } from '@kbn/calculate-auto';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { DataView, DataViewsService } from '@kbn/data-views-plugin/common';
|
||||
import {
|
||||
ALL_VALUE,
|
||||
APMTransactionErrorRateIndicator,
|
||||
SyntheticsAvailabilityIndicator,
|
||||
GetPreviewDataParams,
|
||||
GetPreviewDataResponse,
|
||||
HistogramIndicator,
|
||||
KQLCustomIndicator,
|
||||
MetricCustomIndicator,
|
||||
SyntheticsAvailabilityIndicator,
|
||||
TimesliceMetricIndicator,
|
||||
} from '@kbn/slo-schema';
|
||||
import { assertNever } from '@kbn/std';
|
||||
import moment from 'moment';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { estypes } from '@elastic/elasticsearch';
|
||||
import { DataView, DataViewsService } from '@kbn/data-views-plugin/common';
|
||||
import { getElasticsearchQueryOrThrow } from './transform_generators';
|
||||
|
||||
import { buildParamValues } from './transform_generators/synthetics_availability';
|
||||
import { typedSearch } from '../utils/queries';
|
||||
import { APMTransactionDurationIndicator } from '../domain/models';
|
||||
import { SYNTHETICS_INDEX_PATTERN } from '../../common/constants';
|
||||
import { APMTransactionDurationIndicator, Groupings } from '../domain/models';
|
||||
import { computeSLIForPreview } from '../domain/services';
|
||||
import { typedSearch } from '../utils/queries';
|
||||
import {
|
||||
GetCustomMetricIndicatorAggregation,
|
||||
GetHistogramIndicatorAggregation,
|
||||
GetTimesliceMetricIndicatorAggregation,
|
||||
} from './aggregations';
|
||||
import { SYNTHETICS_INDEX_PATTERN } from '../../common/constants';
|
||||
import { getElasticsearchQueryOrThrow } from './transform_generators';
|
||||
import { buildParamValues } from './transform_generators/synthetics_availability';
|
||||
|
||||
interface Options {
|
||||
range: {
|
||||
|
@ -41,11 +41,11 @@ interface Options {
|
|||
end: number;
|
||||
};
|
||||
interval: string;
|
||||
instanceId?: string;
|
||||
remoteName?: string;
|
||||
groupBy?: string | string[];
|
||||
groupings?: Record<string, unknown>;
|
||||
groupings?: Groupings;
|
||||
groupBy?: string[];
|
||||
}
|
||||
|
||||
export class GetPreviewData {
|
||||
constructor(
|
||||
private esClient: ElasticsearchClient,
|
||||
|
@ -53,7 +53,7 @@ export class GetPreviewData {
|
|||
private dataViewService: DataViewsService
|
||||
) {}
|
||||
|
||||
public async buildRuntimeMappings({ dataViewId }: { dataViewId?: string }) {
|
||||
private async buildRuntimeMappings({ dataViewId }: { dataViewId?: string }) {
|
||||
let dataView: DataView | undefined;
|
||||
if (dataViewId) {
|
||||
try {
|
||||
|
@ -65,12 +65,45 @@ export class GetPreviewData {
|
|||
return dataView?.getRuntimeMappings?.() ?? {};
|
||||
}
|
||||
|
||||
private addExtraTermsOrMultiTermsAgg(
|
||||
perInterval: AggregationsAggregationContainer,
|
||||
groupBy?: string[]
|
||||
): Record<string, AggregationsAggregationContainer> {
|
||||
if (!groupBy || groupBy.length === 0) return { perInterval };
|
||||
if (groupBy.length === 1) {
|
||||
return {
|
||||
perGroup: {
|
||||
terms: {
|
||||
size: 5,
|
||||
field: groupBy[0],
|
||||
},
|
||||
aggs: { perInterval },
|
||||
},
|
||||
perInterval,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
perGroup: {
|
||||
multi_terms: {
|
||||
size: 5,
|
||||
terms: groupBy.map((group) => ({ field: group })),
|
||||
},
|
||||
aggs: { perInterval },
|
||||
},
|
||||
perInterval,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAPMTransactionDurationPreviewData(
|
||||
indicator: APMTransactionDurationIndicator,
|
||||
options: Options
|
||||
): Promise<GetPreviewDataResponse> {
|
||||
const filter: estypes.QueryDslQueryContainer[] = [];
|
||||
this.getGroupingsFilter(options, filter);
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
if (indicator.params.service !== ALL_VALUE)
|
||||
filter.push({
|
||||
match: { 'service.name': indicator.params.service },
|
||||
|
@ -96,7 +129,7 @@ export class GetPreviewData {
|
|||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await typedSearch(this.esClient, {
|
||||
const response = await typedSearch(this.esClient, {
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -113,8 +146,8 @@ export class GetPreviewData {
|
|||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: options.interval,
|
||||
|
@ -146,12 +179,14 @@ export class GetPreviewData {
|
|||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
result.aggregations?.perMinute.buckets.map((bucket) => {
|
||||
const good = (bucket.good?.value as number) ?? 0;
|
||||
const results =
|
||||
// @ts-ignore
|
||||
response.aggregations?.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
|
@ -162,8 +197,28 @@ export class GetPreviewData {
|
|||
bad: total - good,
|
||||
},
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private async getAPMTransactionErrorPreviewData(
|
||||
|
@ -171,7 +226,10 @@ export class GetPreviewData {
|
|||
options: Options
|
||||
): Promise<GetPreviewDataResponse> {
|
||||
const filter: estypes.QueryDslQueryContainer[] = [];
|
||||
this.getGroupingsFilter(options, filter);
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
if (indicator.params.service !== ALL_VALUE)
|
||||
filter.push({
|
||||
match: { 'service.name': indicator.params.service },
|
||||
|
@ -195,7 +253,7 @@ export class GetPreviewData {
|
|||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await typedSearch(this.esClient, {
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -211,8 +269,8 @@ export class GetPreviewData {
|
|||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: options.interval,
|
||||
|
@ -240,22 +298,45 @@ export class GetPreviewData {
|
|||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
return result.aggregations?.perMinute.buckets.map((bucket) => ({
|
||||
date: bucket.key_as_string,
|
||||
sliValue:
|
||||
!!bucket.good && !!bucket.total
|
||||
? computeSLIForPreview(bucket.good.doc_count, bucket.total.doc_count)
|
||||
: null,
|
||||
events: {
|
||||
good: bucket.good?.doc_count ?? 0,
|
||||
bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0),
|
||||
total: bucket.total?.doc_count ?? 0,
|
||||
},
|
||||
}));
|
||||
const results =
|
||||
// @ts-ignore
|
||||
response.aggregations?.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private async getHistogramPreviewData(
|
||||
|
@ -271,13 +352,16 @@ export class GetPreviewData {
|
|||
filterQuery,
|
||||
];
|
||||
|
||||
this.getGroupingsFilter(options, filter);
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
|
||||
const index = options.remoteName
|
||||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await this.esClient.search({
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -288,8 +372,8 @@ export class GetPreviewData {
|
|||
filter,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: timestampField,
|
||||
fixed_interval: options.interval,
|
||||
|
@ -309,22 +393,52 @@ export class GetPreviewData {
|
|||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
return result.aggregations?.perMinute.buckets.map((bucket) => ({
|
||||
date: bucket.key_as_string,
|
||||
sliValue:
|
||||
!!bucket.good && !!bucket.total
|
||||
? computeSLIForPreview(bucket.good.value, bucket.total.value)
|
||||
: null,
|
||||
events: {
|
||||
good: bucket.good?.value ?? 0,
|
||||
bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0),
|
||||
total: bucket.total?.value ?? 0,
|
||||
},
|
||||
}));
|
||||
interface Bucket {
|
||||
key_as_string: string;
|
||||
good: { value: number };
|
||||
total: { value: number };
|
||||
}
|
||||
|
||||
const results =
|
||||
// @ts-ignore buckets not typed properly
|
||||
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private async getCustomMetricPreviewData(
|
||||
|
@ -339,13 +453,17 @@ export class GetPreviewData {
|
|||
{ range: { [timestampField]: { gte: options.range.start, lte: options.range.end } } },
|
||||
filterQuery,
|
||||
];
|
||||
this.getGroupingsFilter(options, filter);
|
||||
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
|
||||
const index = options.remoteName
|
||||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await this.esClient.search({
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -356,8 +474,8 @@ export class GetPreviewData {
|
|||
filter,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: timestampField,
|
||||
fixed_interval: options.interval,
|
||||
|
@ -377,22 +495,52 @@ export class GetPreviewData {
|
|||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
return result.aggregations?.perMinute.buckets.map((bucket) => ({
|
||||
date: bucket.key_as_string,
|
||||
sliValue:
|
||||
!!bucket.good && !!bucket.total
|
||||
? computeSLIForPreview(bucket.good.value, bucket.total.value)
|
||||
: null,
|
||||
events: {
|
||||
good: bucket.good?.value ?? 0,
|
||||
bad: (bucket.total?.value ?? 0) - (bucket.good?.value ?? 0),
|
||||
total: bucket.total?.value ?? 0,
|
||||
},
|
||||
}));
|
||||
interface Bucket {
|
||||
key_as_string: string;
|
||||
good: { value: number };
|
||||
total: { value: number };
|
||||
}
|
||||
|
||||
const results =
|
||||
// @ts-ignore buckets not typed properly
|
||||
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.value ?? 0;
|
||||
const total = bucket.total?.value ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private async getTimesliceMetricPreviewData(
|
||||
|
@ -410,13 +558,16 @@ export class GetPreviewData {
|
|||
filterQuery,
|
||||
];
|
||||
|
||||
this.getGroupingsFilter(options, filter);
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
|
||||
const index = options.remoteName
|
||||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await this.esClient.search({
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -427,8 +578,8 @@ export class GetPreviewData {
|
|||
filter,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: timestampField,
|
||||
fixed_interval: options.interval,
|
||||
|
@ -437,18 +588,39 @@ export class GetPreviewData {
|
|||
max: options.range.end,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
...getCustomMetricIndicatorAggregation.execute('metric'),
|
||||
},
|
||||
aggs: getCustomMetricIndicatorAggregation.execute('metric'),
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
return result.aggregations?.perMinute.buckets.map((bucket) => ({
|
||||
date: bucket.key_as_string,
|
||||
sliValue: !!bucket.metric ? bucket.metric.value : null,
|
||||
}));
|
||||
interface Bucket {
|
||||
key_as_string: string;
|
||||
metric: { value: number };
|
||||
}
|
||||
|
||||
const results =
|
||||
// @ts-ignore buckets not typed properly
|
||||
response.aggregations?.perInterval.buckets.map((bucket: Bucket) => {
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: bucket.metric?.value ?? null,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: bucket.metric?.value ?? null,
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private async getCustomKQLPreviewData(
|
||||
|
@ -464,13 +636,16 @@ export class GetPreviewData {
|
|||
filterQuery,
|
||||
];
|
||||
|
||||
this.getGroupingsFilter(options, filter);
|
||||
const groupingFilters = this.getGroupingFilters(options);
|
||||
if (groupingFilters) {
|
||||
filter.push(...groupingFilters);
|
||||
}
|
||||
|
||||
const index = options.remoteName
|
||||
? `${options.remoteName}:${indicator.params.index}`
|
||||
: indicator.params.index;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await typedSearch(this.esClient, {
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -481,8 +656,8 @@ export class GetPreviewData {
|
|||
filter,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: timestampField,
|
||||
fixed_interval: options.interval,
|
||||
|
@ -496,41 +671,52 @@ export class GetPreviewData {
|
|||
total: { filter: totalQuery },
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
return result.aggregations?.perMinute.buckets.map((bucket) => ({
|
||||
date: bucket.key_as_string,
|
||||
sliValue:
|
||||
!!bucket.good && !!bucket.total
|
||||
? computeSLIForPreview(bucket.good.doc_count, bucket.total.doc_count)
|
||||
: null,
|
||||
events: {
|
||||
good: bucket.good?.doc_count ?? 0,
|
||||
bad: (bucket.total?.doc_count ?? 0) - (bucket.good?.doc_count ?? 0),
|
||||
total: bucket.total?.doc_count ?? 0,
|
||||
},
|
||||
}));
|
||||
const results =
|
||||
// @ts-ignore
|
||||
response.aggregations?.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
private getGroupingsFilter(options: Options, filter: estypes.QueryDslQueryContainer[]) {
|
||||
const groupingsKeys = Object.keys(options.groupings || []);
|
||||
|
||||
private getGroupingFilters(options: Options): estypes.QueryDslQueryContainer[] | undefined {
|
||||
const groupingsKeys = Object.keys(options.groupings ?? {});
|
||||
if (groupingsKeys.length) {
|
||||
groupingsKeys.forEach((key) => {
|
||||
filter.push({
|
||||
term: { [key]: options.groupings?.[key] },
|
||||
});
|
||||
});
|
||||
} else if (options.instanceId && options.instanceId !== ALL_VALUE && options.groupBy) {
|
||||
const instanceIdPart = options.instanceId.split(',');
|
||||
const groupByPart = Array.isArray(options.groupBy) ? options.groupBy : [options.groupBy];
|
||||
groupByPart.forEach((groupBy, index) => {
|
||||
filter.push({
|
||||
term: { [groupBy]: instanceIdPart[index] },
|
||||
});
|
||||
});
|
||||
return groupingsKeys.map((key) => ({ term: { [key]: options.groupings![key] } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -561,7 +747,7 @@ export class GetPreviewData {
|
|||
? `${options.remoteName}:${SYNTHETICS_INDEX_PATTERN}`
|
||||
: SYNTHETICS_INDEX_PATTERN;
|
||||
|
||||
const result = await this.esClient.search({
|
||||
const response = await typedSearch(this.esClient, {
|
||||
index,
|
||||
runtime_mappings: await this.buildRuntimeMappings({
|
||||
dataViewId: indicator.params.dataViewId,
|
||||
|
@ -577,11 +763,15 @@ export class GetPreviewData {
|
|||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
perMinute: {
|
||||
aggs: this.addExtraTermsOrMultiTermsAgg(
|
||||
{
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: '10m',
|
||||
fixed_interval: options.interval,
|
||||
extended_bounds: {
|
||||
min: options.range.start,
|
||||
max: options.range.end,
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
good: {
|
||||
|
@ -591,13 +781,6 @@ export class GetPreviewData {
|
|||
},
|
||||
},
|
||||
},
|
||||
bad: {
|
||||
filter: {
|
||||
term: {
|
||||
'monitor.status': 'down',
|
||||
},
|
||||
},
|
||||
},
|
||||
total: {
|
||||
filter: {
|
||||
match_all: {},
|
||||
|
@ -605,53 +788,69 @@ export class GetPreviewData {
|
|||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
options.groupBy
|
||||
),
|
||||
});
|
||||
|
||||
const data: GetPreviewDataResponse = [];
|
||||
const results =
|
||||
// @ts-ignore
|
||||
response.aggregations?.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// @ts-ignore buckets is not improperly typed
|
||||
result.aggregations?.perMinute.buckets.forEach((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const bad = bucket.bad?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
data.push({
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad,
|
||||
total,
|
||||
},
|
||||
// @ts-ignore
|
||||
const groups = response.aggregations?.perGroup?.buckets?.reduce((acc, group) => {
|
||||
// @ts-ignore
|
||||
acc[group.key] = group.perInterval.buckets.map((bucket) => {
|
||||
const good = bucket.good?.doc_count ?? 0;
|
||||
const total = bucket.total?.doc_count ?? 0;
|
||||
return {
|
||||
date: bucket.key_as_string,
|
||||
sliValue: computeSLIForPreview(good, total),
|
||||
events: {
|
||||
good,
|
||||
bad: total - good,
|
||||
total,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return data;
|
||||
return { results, groups };
|
||||
}
|
||||
|
||||
public async execute(params: GetPreviewDataParams): Promise<GetPreviewDataResponse> {
|
||||
try {
|
||||
// If the time range is 24h or less, then we want to use a 1m bucket for the
|
||||
// Timeslice metric so that the chart is as close to the evaluation as possible.
|
||||
// If the time range is 24h or less, then we want to use the timeslice duration for the buckets
|
||||
// so that the chart is as close to the evaluation as possible.
|
||||
// Otherwise due to how the statistics work, the values might not look like
|
||||
// they've breached the threshold.
|
||||
const rangeDuration = moment(params.range.to).diff(params.range.from, 'ms');
|
||||
const bucketSize =
|
||||
params.indicator.type === 'sli.metric.timeslice' &&
|
||||
rangeDuration <= 86_400_000 &&
|
||||
params.objective?.timesliceWindow
|
||||
rangeDuration <= 86_400_000 && params.objective?.timesliceWindow
|
||||
? params.objective.timesliceWindow.asMinutes()
|
||||
: Math.max(
|
||||
calculateAuto.near(100, moment.duration(rangeDuration, 'ms'))?.asMinutes() ?? 0,
|
||||
1
|
||||
);
|
||||
|
||||
const options: Options = {
|
||||
instanceId: params.instanceId,
|
||||
range: { start: params.range.from.getTime(), end: params.range.to.getTime() },
|
||||
groupBy: params.groupBy,
|
||||
remoteName: params.remoteName,
|
||||
groupings: params.groupings,
|
||||
interval: `${bucketSize}m`,
|
||||
groupBy: params.groupBy?.filter((value) => value !== ALL_VALUE),
|
||||
};
|
||||
|
||||
const type = params.indicator.type;
|
||||
|
@ -674,7 +873,7 @@ export class GetPreviewData {
|
|||
assertNever(type);
|
||||
}
|
||||
} catch (err) {
|
||||
return [];
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue