[AO] - Add scaffolding and the main chart to the Logs threshold Alert Details page (#153081)

## Summary
### This is a kickoff PR; more PRs will follow up. 

It closes #152738
<img width="1191" alt="Screenshot 2023-03-23 at 13 10 09"
src="https://user-images.githubusercontent.com/6838659/227239913-4516cf44-92f6-454c-a7af-72dafb12cfb6.png">


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Faisal Kanout 2023-03-28 18:15:47 +02:00 committed by GitHub
parent 378c5c198a
commit c1a8b90f51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 147 additions and 27 deletions

View file

@ -401,3 +401,8 @@ export const isOptimizableGroupedThreshold = (
return false;
}
};
export interface ExecutionTimeRange {
gte?: number;
lte: number;
}

View file

@ -85,6 +85,13 @@ export const getLogAlertsChartPreviewDataRequestPayloadRT = rt.type({
logView: persistedLogViewReferenceRT,
alertParams: getLogAlertsChartPreviewDataAlertParamsSubsetRT,
buckets: rt.number,
executionTimeRange: rt.union([
rt.undefined,
rt.type({
gte: rt.number,
lte: rt.number,
}),
]),
}),
});

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ALERT_DURATION, ALERT_END } from '@kbn/rule-data-utils';
import moment from 'moment';
import React from 'react';
import { type PartialCriterion } from '../../../../../common/alerting/logs/log_threshold';
import { CriterionPreview } from '../expression_editor/criterion_preview_chart';
import { AlertDetailsAppSectionProps } from './types';
const AlertDetailsAppSection = ({ rule, alert }: AlertDetailsAppSectionProps) => {
const ruleWindowSizeMS = moment
.duration(rule.params.timeSize, rule.params.timeUnit)
.asMilliseconds();
const alertDurationMS = alert.fields[ALERT_DURATION]! / 1000;
const TWENTY_TIMES_RULE_WINDOW_MS = 20 * ruleWindowSizeMS;
/**
* This is part or the requirements (RFC).
* If the alert is less than 20 units of `FOR THE LAST <x> <units>` then we should draw a time range of 20 units.
* IE. The user set "FOR THE LAST 5 minutes" at a minimum we should show 100 minutes.
*/
const rangeFrom =
alertDurationMS < TWENTY_TIMES_RULE_WINDOW_MS
? Number(moment(alert.start).subtract(TWENTY_TIMES_RULE_WINDOW_MS, 'millisecond').format('x'))
: Number(moment(alert.start).subtract(ruleWindowSizeMS, 'millisecond').format('x'));
const rangeTo = alert.active
? Date.now()
: Number(moment(alert.fields[ALERT_END]).add(ruleWindowSizeMS, 'millisecond').format('x'));
return (
// Create a chart per-criteria
<EuiFlexGroup>
{rule.params.criteria.map((criteria) => {
const chartCriterion = criteria as PartialCriterion;
return (
<EuiFlexItem>
<CriterionPreview
key={chartCriterion.field}
ruleParams={rule.params}
sourceId={rule.params.logView.logViewId}
chartCriterion={chartCriterion}
showThreshold={true}
executionTimeRange={{ gte: rangeFrom, lte: rangeTo }}
/>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export default AlertDetailsAppSection;

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Rule } from '@kbn/alerting-plugin/common';
import { TopAlert } from '@kbn/observability-plugin/public';
import { PartialRuleParams } from '../../../../../common/alerting/logs/log_threshold';
export interface AlertDetailsAppSectionProps {
rule: Rule<PartialRuleParams>;
alert: TopAlert;
}

View file

@ -21,6 +21,7 @@ import {
import { EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ExecutionTimeRange } from '../../../../types';
import {
ChartContainer,
LoadingState,
@ -56,6 +57,7 @@ interface Props {
chartCriterion: Partial<Criterion>;
sourceId: string;
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
}
export const CriterionPreview: React.FC<Props> = ({
@ -63,6 +65,7 @@ export const CriterionPreview: React.FC<Props> = ({
chartCriterion,
sourceId,
showThreshold,
executionTimeRange,
}) => {
const chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset | null = useMemo(() => {
const { field, comparator, value } = chartCriterion;
@ -106,6 +109,7 @@ export const CriterionPreview: React.FC<Props> = ({
threshold={ruleParams.count}
chartAlertParams={chartAlertParams}
showThreshold={showThreshold}
executionTimeRange={executionTimeRange}
/>
);
};
@ -116,6 +120,7 @@ interface ChartProps {
threshold?: Threshold;
chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset;
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
}
const CriterionPreviewChart: React.FC<ChartProps> = ({
@ -124,6 +129,7 @@ const CriterionPreviewChart: React.FC<ChartProps> = ({
threshold,
chartAlertParams,
showThreshold,
executionTimeRange,
}) => {
const { uiSettings } = useKibana().services;
const isDarkMode = uiSettings?.get('theme:darkMode') || false;
@ -138,6 +144,7 @@ const CriterionPreviewChart: React.FC<ChartProps> = ({
sourceId,
ruleParams: chartAlertParams,
buckets,
executionTimeRange,
});
useDebounce(

View file

@ -8,6 +8,7 @@
import { useState, useMemo } from 'react';
import { HttpHandler } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ExecutionTimeRange } from '../../../../../types';
import { useTrackedPromise } from '../../../../../utils/use_tracked_promise';
import {
GetLogAlertsChartPreviewDataSuccessResponsePayload,
@ -22,11 +23,16 @@ interface Options {
sourceId: string;
ruleParams: GetLogAlertsChartPreviewDataAlertParamsSubset;
buckets: number;
executionTimeRange?: ExecutionTimeRange;
}
export const useChartPreviewData = ({ sourceId, ruleParams, buckets }: Options) => {
export const useChartPreviewData = ({
sourceId,
ruleParams,
buckets,
executionTimeRange,
}: Options) => {
const { http } = useKibana().services;
const [chartPreviewData, setChartPreviewData] = useState<
GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series']
>([]);
@ -36,7 +42,13 @@ export const useChartPreviewData = ({ sourceId, ruleParams, buckets }: Options)
cancelPreviousOn: 'creation',
createPromise: async () => {
setHasError(false);
return await callGetChartPreviewDataAPI(sourceId, http!.fetch, ruleParams, buckets);
return await callGetChartPreviewDataAPI(
sourceId,
http!.fetch,
ruleParams,
buckets,
executionTimeRange
);
},
onResolve: ({ data: { series } }) => {
setHasError(false);
@ -66,7 +78,8 @@ export const callGetChartPreviewDataAPI = async (
sourceId: string,
fetch: HttpHandler,
alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset,
buckets: number
buckets: number,
executionTimeRange?: ExecutionTimeRange
) => {
const response = await fetch(LOG_ALERTS_CHART_PREVIEW_DATA_PATH, {
method: 'POST',
@ -76,6 +89,7 @@ export const callGetChartPreviewDataAPI = async (
logView: { type: 'log-view-reference', logViewId: sourceId },
alertParams,
buckets,
executionTimeRange,
},
})
),

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public';
import { lazy } from 'react';
import {
LOG_DOCUMENT_COUNT_RULE_TYPE_ID,
PartialRuleParams,
@ -33,6 +34,7 @@ export function createLogThresholdRuleType(
documentationUrl(docLinks) {
return `${docLinks.links.observability.logsThreshold}`;
},
alertDetailsAppSection: lazy(() => import('./components/alert_details_app_section')),
ruleParamsExpression,
validate: validateExpression,
defaultActionMessage: i18n.translate(

View file

@ -112,6 +112,11 @@ export interface LensOptions {
breakdownSize: number;
}
export interface ExecutionTimeRange {
gte: number;
lte: number;
}
type PropsOf<T> = T extends React.ComponentType<infer ComponentProps> ? ComponentProps : never;
type FirstArgumentOf<Func> = Func extends (arg1: infer FirstArgument, ...rest: any[]) => any
? FirstArgument

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import {
ExecutionTimeRange,
GroupedSearchQueryResponse,
GroupedSearchQueryResponseRT,
isOptimizedGroupedSearchQueryResponse,
@ -35,7 +36,8 @@ export async function getChartPreviewData(
resolvedLogView: ResolvedLogView,
callWithRequest: KibanaFramework['callWithRequest'],
alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset,
buckets: number
buckets: number,
executionTimeRange?: ExecutionTimeRange
) {
const { indices, timestampField, runtimeMappings } = resolvedLogView;
const { groupBy, timeSize, timeUnit } = alertParams;
@ -47,11 +49,10 @@ export async function getChartPreviewData(
timeSize: timeSize * buckets,
};
const executionTimestamp = Date.now();
const { rangeFilter } = buildFiltersFromCriteria(
expandedAlertParams,
timestampField,
executionTimestamp
executionTimeRange
);
const query = isGrouped
@ -60,14 +61,14 @@ export async function getChartPreviewData(
timestampField,
indices,
runtimeMappings,
executionTimestamp
executionTimeRange
)
: getUngroupedESQuery(
expandedAlertParams,
timestampField,
indices,
runtimeMappings,
executionTimestamp
executionTimeRange
);
if (!query) {

View file

@ -140,7 +140,9 @@ const baseRuleParams: Pick<RuleParams, 'count' | 'timeSize' | 'timeUnit' | 'logV
const TIMESTAMP_FIELD = '@timestamp';
const FILEBEAT_INDEX = 'filebeat-*';
const EXECUTION_TIMESTAMP = new Date('2022-01-01T00:00:00.000Z').valueOf();
const EXECUTION_TIMERANGE = {
lte: new Date('2022-01-01T00:00:00.000Z').valueOf(),
};
const runtimeMappings: estypes.MappingRuntimeFields = {
runtime_field: {
@ -173,7 +175,7 @@ describe('Log threshold executor', () => {
...baseRuleParams,
criteria: positiveCriteria,
};
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMESTAMP);
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMERANGE);
expect(filters.mustFilters).toEqual(expectedPositiveFilterClauses);
});
@ -182,14 +184,14 @@ describe('Log threshold executor', () => {
...baseRuleParams,
criteria: negativeCriteria,
};
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMESTAMP);
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMERANGE);
expect(filters.mustNotFilters).toEqual(expectedNegativeFilterClauses);
});
test('Handles time range', () => {
const ruleParams: RuleParams = { ...baseRuleParams, criteria: [] };
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMESTAMP);
const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD, EXECUTION_TIMERANGE);
expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].gte).toBe('number');
expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].lte).toBe('number');
expect(filters.rangeFilter.range[TIMESTAMP_FIELD].format).toBe('epoch_millis');
@ -212,7 +214,7 @@ describe('Log threshold executor', () => {
TIMESTAMP_FIELD,
FILEBEAT_INDEX,
runtimeMappings,
EXECUTION_TIMESTAMP
EXECUTION_TIMERANGE
);
expect(query).toEqual({
index: 'filebeat-*',
@ -264,7 +266,7 @@ describe('Log threshold executor', () => {
TIMESTAMP_FIELD,
FILEBEAT_INDEX,
runtimeMappings,
EXECUTION_TIMESTAMP
EXECUTION_TIMERANGE
);
expect(query).toEqual({
@ -344,7 +346,7 @@ describe('Log threshold executor', () => {
TIMESTAMP_FIELD,
FILEBEAT_INDEX,
runtimeMappings,
EXECUTION_TIMESTAMP
EXECUTION_TIMERANGE
);
expect(query).toEqual({

View file

@ -46,6 +46,7 @@ import {
RatioRuleParams,
UngroupedSearchQueryResponse,
UngroupedSearchQueryResponseRT,
ExecutionTimeRange,
} from '../../../../common/alerting/logs/log_threshold';
import { decodeOrThrow } from '../../../../common/runtime_types';
import { getLogsAppAlertUrl } from '../../../../common/formatters/alert_link';
@ -346,20 +347,23 @@ const getESQuery = (
runtimeMappings: estypes.MappingRuntimeFields,
executionTimestamp: number
) => {
const executionTimeRange = {
lte: executionTimestamp,
};
return hasGroupBy(alertParams)
? getGroupedESQuery(
alertParams,
timestampField,
indexPattern,
runtimeMappings,
executionTimestamp
executionTimeRange
)
: getUngroupedESQuery(
alertParams,
timestampField,
indexPattern,
runtimeMappings,
executionTimestamp
executionTimeRange
);
};
@ -641,14 +645,14 @@ export const processGroupByRatioResults = (
export const buildFiltersFromCriteria = (
params: Pick<RuleParams, 'timeSize' | 'timeUnit'> & { criteria: CountCriteria },
timestampField: string,
executionTimestamp: number
executionTimeRange?: ExecutionTimeRange
) => {
const { timeSize, timeUnit, criteria } = params;
const interval = `${timeSize}${timeUnit}`;
const intervalAsSeconds = getIntervalInSeconds(interval);
const intervalAsMs = intervalAsSeconds * 1000;
const to = executionTimestamp;
const from = to - intervalAsMs;
const to = executionTimeRange?.lte || Date.now();
const from = executionTimeRange?.gte || to - intervalAsMs;
const positiveCriteria = criteria.filter((criterion) =>
positiveComparators.includes(criterion.comparator)
@ -699,7 +703,7 @@ export const getGroupedESQuery = (
timestampField: string,
index: string,
runtimeMappings: estypes.MappingRuntimeFields,
executionTimestamp: number
executionTimeRange?: ExecutionTimeRange
): estypes.SearchRequest | undefined => {
// IMPORTANT:
// For the group by scenario we need to account for users utilizing "less than" configurations
@ -721,7 +725,7 @@ export const getGroupedESQuery = (
const { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria(
params,
timestampField,
executionTimestamp
executionTimeRange
);
if (isOptimizableGroupedThreshold(comparator, value)) {
@ -812,12 +816,12 @@ export const getUngroupedESQuery = (
timestampField: string,
index: string,
runtimeMappings: estypes.MappingRuntimeFields,
executionTimestamp: number
executionTimeRange?: ExecutionTimeRange
): object => {
const { rangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria(
params,
timestampField,
executionTimestamp
executionTimeRange
);
const body: estypes.SearchRequest['body'] = {

View file

@ -29,7 +29,7 @@ export const initGetLogAlertsChartPreviewDataRoute = ({
},
framework.router.handleLegacyErrors(async (requestContext, request, response) => {
const {
data: { logView, buckets, alertParams },
data: { logView, buckets, alertParams, executionTimeRange },
} = request.body;
const [, , { logViews }] = await getStartServices();
@ -41,7 +41,8 @@ export const initGetLogAlertsChartPreviewDataRoute = ({
resolvedLogView,
framework.callWithRequest,
alertParams,
buckets
buckets,
executionTimeRange
);
return response.ok({