diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index d13140f0be16c..6bd96e012548d 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -18,6 +18,7 @@ "data", "features", "ruleRegistry", + "timelines", "triggersActionsUi" ], "ui": true, diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c7faa28b04685..53b5300e556c5 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -31,7 +31,7 @@ import { } from '@kbn/rule-data-utils/target/technical_field_names'; import moment from 'moment-timezone'; import React, { useMemo } from 'react'; -import type { TopAlertResponse } from '../'; +import type { TopAlert, TopAlertResponse } from '../'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; import { asDuration } from '../../../../common/utils/formatters'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; @@ -39,6 +39,7 @@ import { decorateResponse } from '../decorate_response'; import { SeverityBadge } from '../severity_badge'; type AlertsFlyoutProps = { + alert?: TopAlert; alerts?: TopAlertResponse[]; isInApp?: boolean; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; @@ -46,6 +47,7 @@ type AlertsFlyoutProps = { } & EuiFlyoutProps; export function AlertsFlyout({ + alert, alerts, isInApp = false, observabilityRuleTypeRegistry, @@ -59,9 +61,12 @@ export function AlertsFlyout({ const decoratedAlerts = useMemo(() => { return decorateResponse(alerts ?? [], observabilityRuleTypeRegistry); }, [alerts, observabilityRuleTypeRegistry]); - const alert = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); - if (!alert) { + let alertData = alert; + if (!alertData) { + alertData = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); + } + if (!alertData) { return null; } @@ -70,45 +75,45 @@ export function AlertsFlyout({ title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', { defaultMessage: 'Status', }), - description: alert.active ? 'Active' : 'Recovered', + description: alertData.active ? 'Active' : 'Recovered', }, { title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', { defaultMessage: 'Severity', }), - description: , + description: , }, { title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', { defaultMessage: 'Triggered', }), description: ( - {moment(alert.start).format(dateFormat)} + {moment(alertData.start).format(dateFormat)} ), }, { title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', { defaultMessage: 'Duration', }), - description: asDuration(alert.fields[ALERT_DURATION], { extended: true }), + description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }), }, { title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', { defaultMessage: 'Expected value', }), - description: alert.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', + description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', { defaultMessage: 'Actual value', }), - description: alert.fields[ALERT_EVALUATION_VALUE] ?? '-', + description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', { defaultMessage: 'Rule type', }), - description: alert.fields[RULE_CATEGORY] ?? '-', + description: alertData.fields[RULE_CATEGORY] ?? '-', }, ]; @@ -116,10 +121,10 @@ export function AlertsFlyout({ -

{alert.fields[RULE_NAME]}

+

{alertData.fields[RULE_NAME]}

- {alert.reason} + {alertData.reason}
@@ -129,11 +134,11 @@ export function AlertsFlyout({ listItems={overviewListItems} /> - {alert.link && !isInApp && ( + {alertData.link && !isInApp && ( - + View in app diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index c0a08fa7faac7..b2d44f9a598dd 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -7,17 +7,17 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo, useState } from 'react'; -import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; +import { IIndexPattern, SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { callObservabilityApi } from '../../services/call_observability_api'; export function AlertsSearchBar({ + dynamicIndexPattern, rangeFrom, rangeTo, onQueryChange, query, }: { + dynamicIndexPattern: IIndexPattern[]; rangeFrom?: string; rangeTo?: string; query?: string; @@ -31,16 +31,9 @@ export function AlertsSearchBar({ }, []); const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery'); - const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', - }); - }, []); - return ( void) => void; +} + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', { + defaultMessage: 'Status', + }), + id: ALERT_STATUS, + initialWidth: 79, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.triggeredColumnDescription', { + defaultMessage: 'Triggered', + }), + id: ALERT_START, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.durationColumnDescription', { + defaultMessage: 'Duration', + }), + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.severityColumnDescription', { + defaultMessage: 'Severity', + }), + id: ALERT_SEVERITY_LEVEL, + initialWidth: 102, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonColumnDescription', { + defaultMessage: 'Reason', + }), + linkField: '*', + id: RULE_NAME, + initialWidth: 400, + }, +]; + +const NO_ROW_RENDER: RowRenderer[] = []; + +const trailingControlColumns: never[] = []; + +export function AlertsTableTGrid(props: AlertsTableTGridProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { prepend } = core.http.basePath; + const { indexName, rangeFrom, rangeTo, kuery, status, setRefetch } = props; + const [flyoutAlert, setFlyoutAlert] = useState(undefined); + const handleFlyoutClose = () => setFlyoutAlert(undefined); + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + + const leadingControlColumns = [ + { + id: 'expand', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + setFlyoutAlert(alert)} + /> + ); + }, + }, + { + id: 'view_in_app', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + + ); + }, + }, + ]; + + return ( + <> + {flyoutAlert && ( + + + + )} + {timelines.getTGrid<'standalone'>({ + type: 'standalone', + columns, + deletedEventIds: [], + end: rangeTo, + filters: [], + indexNames: [indexName], + itemsPerPage: 10, + itemsPerPageOptions: [10, 25, 50], + loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { + defaultMessage: 'loading alerts', + }), + footerText: i18n.translate('xpack.observability.alertsTable.footerTextLabel', { + defaultMessage: 'alerts', + }), + query: { + query: `${ALERT_STATUS}: ${status}${kuery !== '' ? ` and ${kuery}` : ''}`, + language: 'kuery', + }, + renderCellValue: getRenderCellValue({ rangeFrom, rangeTo, setFlyoutAlert }), + rowRenderers: NO_ROW_RENDER, + start: rangeFrom, + setRefetch, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + }, + ], + leadingControlColumns, + trailingControlColumns, + unit: (totalAlerts: number) => + i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', { + values: { totalAlerts }, + defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}', + }), + })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx new file mode 100644 index 0000000000000..38919857e86c1 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx @@ -0,0 +1,84 @@ +/* + * 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 { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RULE_ID, RULE_NAME } from '@kbn/rule-data-utils/target/technical_field_names'; +import React, { useState } from 'react'; +import { format, parse } from 'url'; + +import { parseTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; +import type { ActionProps } from '../../../../timelines/common'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +export function RowCellActionsRender({ data }: ActionProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { prepend } = core.http.basePath; + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const parsedFields = parseTechnicalFields(dataFieldEs); + const formatter = observabilityRuleTypeRegistry.getFormatter(parsedFields[RULE_ID]!); + const formatted = { + link: undefined, + reason: parsedFields[RULE_NAME]!, + ...(formatter?.({ fields: parsedFields, formatters: { asDuration, asPercent } }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + const link = parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }) + : undefined; + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + > + Actions +
+ + + + + + + {i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', { + defaultMessage: 'View in app', + })} + + + +
+
+
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 6f696a70665ce..fed9ee0be3a4a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -7,21 +7,20 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertStatus } from '../../../common/typings'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; -import { callObservabilityApi } from '../../services/call_observability_api'; import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; -import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertsTable } from './alerts_table'; +import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -41,6 +40,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { core, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); + const refetch = useRef<() => void>(); const { query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; @@ -59,37 +59,52 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); - const { data: alerts } = useFetcher( - ({ signal }) => { - const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); + + const dynamicIndexPattern = useMemo( + () => (dynamicIndexPatternResp ? [dynamicIndexPatternResp] : []), + [dynamicIndexPatternResp] + ); + + const setStatusFilter = useCallback( + (value: AlertStatus) => { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }, + [history] + ); - if (!start || !end) { - return; + const onQueryChange = useCallback( + ({ dateRange, query }) => { + if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { + return refetch.current && refetch.current(); } - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/top', - params: { - query: { - start, - end, - kuery, - status, - }, - }, + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), }); }, - [kuery, rangeFrom, rangeTo, status] + [history, rangeFrom, rangeTo, kuery] ); - function setStatusFilter(value: AlertStatus) { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('status', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - } + const setRefetch = useCallback((ref) => { + refetch.current = ref; + }, []); return ( { - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - }} + onQueryChange={onQueryChange} /> @@ -162,7 +167,14 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
- + 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + /> diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx new file mode 100644 index 0000000000000..1cd86631197c4 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -0,0 +1,101 @@ +/* + * 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 { EuiIconTip, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { CellValueElementProps, TimelineNonEcsData } from '../../../../timelines/common'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; +import { asDuration } from '../../../common/utils/formatters'; +import { SeverityBadge } from './severity_badge'; +import { TopAlert } from '.'; +import { decorateResponse } from './decorate_response'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const getRenderCellValue = ({ + rangeTo, + rangeFrom, + setFlyoutAlert, +}: { + rangeTo: string; + rangeFrom: string; + setFlyoutAlert: (data: TopAlert) => void; +}) => { + return ({ columnId, data, linkValues }: CellValueElementProps) => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]); + + switch (columnId) { + case ALERT_STATUS: + return value !== 'closed' ? ( + + ) : ( + + ); + case ALERT_START: + return ; + case ALERT_DURATION: + return asDuration(Number(value), { extended: true }); + case ALERT_SEVERITY_LEVEL: + return ; + case RULE_NAME: + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + + return ( + setFlyoutAlert && setFlyoutAlert(alert)}>{alert.reason} + ); + default: + return <>{value}; + } + }; +}; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index b6ed0a0a3d17f..8aa184bca913f 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../cases/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../rule_registry/tsconfig.json" }, + { "path": "../timelines/tsconfig.json"}, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 039424d34bfa1..fe3504c84115b 100644 --- a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -6,22 +6,56 @@ */ import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; +import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import { FieldMap } from './types'; +const NumberFromString = new t.Type( + 'NumberFromString', + (u): u is number => typeof u === 'number', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + const d = Number(s); + return isNaN(d) ? t.failure(u, c) : t.success(d); + }), + (a) => a +); + +const BooleanFromString = new t.Type( + 'BooleanFromString', + (u): u is boolean => typeof u === 'boolean', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + switch (s.toLowerCase().trim()) { + case '1': + case 'true': + case 'yes': + return t.success(true); + case '0': + case 'false': + case 'no': + case null: + return t.success(false); + default: + return t.failure(u, c); + } + }), + (a) => a +); + const esFieldTypeMap = { keyword: t.string, text: t.string, date: t.string, - boolean: t.boolean, - byte: t.number, - long: t.number, - integer: t.number, - short: t.number, - double: t.number, - float: t.number, - scaled_float: t.number, - unsigned_long: t.number, + boolean: t.union([t.number, BooleanFromString]), + byte: t.union([t.number, NumberFromString]), + long: t.union([t.number, NumberFromString]), + integer: t.union([t.number, NumberFromString]), + short: t.union([t.number, NumberFromString]), + double: t.union([t.number, NumberFromString]), + float: t.union([t.number, NumberFromString]), + scaled_float: t.union([t.number, NumberFromString]), + unsigned_long: t.union([t.number, NumberFromString]), flattened: t.record(t.string, t.array(t.string)), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index ac6f6e52db1e2..b71cbb4c082ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,7 +130,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -164,7 +164,13 @@ export const EventsCountComponent = ({ > - + + {totalCount} {footerText} + + } + > {totalCount} diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index b242c0ec2a4a7..8bb4e6cb45853 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -26,13 +26,15 @@ type TGridComponent = TGridProps & { store?: Store; storage: Storage; data?: DataPublicPluginStart; + setStore: (store: Store) => void; }; export const TGrid = (props: TGridComponent) => { - const { store, storage, ...tGridProps } = props; + const { store, storage, setStore, ...tGridProps } = props; let tGridStore = store; if (!tGridStore && props.type === 'standalone') { tGridStore = createStore(initialTGridState, storage); + setStore(tGridStore); } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx index 59cc18767af21..652cb6a5dae33 100644 --- a/x-pack/plugins/timelines/public/components/loading/index.tsx +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -17,7 +17,7 @@ SpinnerFlexItem.displayName = 'SpinnerFlexItem'; export interface LoadingPanelProps { dataTestSubj?: string; - text: string; + text: string | React.ReactNode; height: number | string; showBorder?: boolean; width: number | string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts index fc566da8c58a2..6c793e132b7e3 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -23,17 +23,18 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 23e94b92eaf3d..c164d0026fdf8 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -135,7 +135,7 @@ const TgridActionTdCell = ({ rowIndex, hasRowRenderers, onRuleChange, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, showNotes = false, tabType, @@ -267,7 +267,7 @@ export const DataDrivenColumns = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index dca3b84eb84b7..2db1bde08bd0c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -58,7 +58,7 @@ export const EventColumnView = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, @@ -82,7 +82,6 @@ export const EventColumnView = React.memo( .join(' '), [columnHeaders, data] ); - const leadingActionCells = useMemo( () => leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx index 2978759b6d148..b7fb0b40c0345 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -110,7 +110,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -144,7 +144,7 @@ export const EventsCountComponent = ({ > - + {totalCount} @@ -305,7 +305,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={`${loadingText}...`} + text={loadingText} width="100%" /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75aae2ed55c4b..c267a0e57dd2c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -40,6 +40,7 @@ import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; import * as i18n from './translations'; +import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -103,7 +104,9 @@ export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; deletedEventIds: Readonly; end: string; + loadingText: React.ReactNode; filters: Filter[]; + footerText: React.ReactNode; headerFilterGroup?: React.ReactNode; height?: number; indexNames: string[]; @@ -113,6 +116,7 @@ export interface TGridStandaloneProps { onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setRefetch: (ref: () => void) => void; start: string; sort: SortColumnTimeline[]; utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; @@ -120,13 +124,17 @@ export interface TGridStandaloneProps { leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; data?: DataPublicPluginStart; + unit: (total: number) => React.ReactNode; } +const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC = ({ columns, deletedEventIds, end, + loadingText, filters, + footerText, headerFilterGroup, indexNames, itemsPerPage, @@ -135,6 +143,7 @@ const TGridStandaloneComponent: React.FC = ({ query, renderCellValue, rowRenderers, + setRefetch, start, sort, utilityBar, @@ -142,6 +151,7 @@ const TGridStandaloneComponent: React.FC = ({ leadingControlColumns, trailingControlColumns, data, + unit = basicUnit, }) => { const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -155,7 +165,6 @@ const TGridStandaloneComponent: React.FC = ({ queryFields, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); useEffect(() => { dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading })); }, [dispatch, isQueryLoading]); @@ -216,6 +225,7 @@ const TGridStandaloneComponent: React.FC = ({ skip: !canQueryTimeline, data, }); + setRefetch(refetch); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), @@ -268,71 +278,81 @@ const TGridStandaloneComponent: React.FC = ({ showCheckboxes: false, }) ); + dispatch( + tGridActions.initializeTGridSettings({ + footerText, + id: STANDALONE_ID, + loadingText, + unit, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - - {canQueryTimeline ? ( - <> - - {HeaderSectionContent} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - - - -